diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 1aeb9d0..4a4c1aa 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,91 +1,71 @@ name: CI/CD - on: push: branches: - dev - main - pull_request: branches: - main - env: JAVA_VERSION: "21" - jobs: backend-unit-tests: name: Backend Unit Tests runs-on: ubuntu-latest - defaults: run: working-directory: backend - steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up JDK ${{ env.JAVA_VERSION }} uses: actions/setup-java@v4 with: distribution: temurin java-version: ${{ env.JAVA_VERSION }} - - name: Make Gradle wrapper executable run: chmod +x gradlew - - name: Run unit tests run: ./gradlew test - deploy: name: Deploy needs: backend-unit-tests - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up JDK ${{ env.JAVA_VERSION }} uses: actions/setup-java@v4 with: distribution: temurin java-version: ${{ env.JAVA_VERSION }} - - name: Build boot jar working-directory: backend run: | chmod +x gradlew ./gradlew bootJar - - name: Stage jar for Docker working-directory: backend run: | BOOT_JAR=$(find build/libs -name "*.jar" ! -name "*-plain.jar" | head -n 1) cp "$BOOT_JAR" app.jar - - name: Login to Docker registry run: | echo "${{ secrets.REGISTRY_PASSWORD }}" | \ docker login git.${{ secrets.DOMAIN }} \ -u "${{ secrets.REGISTRY_USER }}" \ --password-stdin - - name: Build Docker image run: | docker build \ -t git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest \ backend/ - - name: Push Docker image run: | docker push \ git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest - - name: Deploy to VPS run: | mkdir -p ~/.ssh @@ -93,14 +73,126 @@ jobs: chmod 600 ~/.ssh/key ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts - # Write secrets to VPS (only if they've changed or first deploy) - ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ + + ssh -i ~/.ssh/key \ + "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ "mkdir -p ~/programs/etf-oglasi-server/config" - echo "${{ secrets.FIREBASE_CREDENTIALS }}" | \ - ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ - "cat > ~/programs/etf-oglasi-server/config/firebase.json" - - ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ + ssh -i ~/.ssh/key \ + "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ "cd ~/programs/etf-oglasi-server && docker compose pull && docker compose up -d" + + mobile-release: + name: Mobile Release + needs: backend-unit-tests + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Accept Android SDK licenses + run: yes | sdkmanager --licenses > /dev/null || true + + - name: Determine next mobile version + id: version + run: | + git fetch --tags + LAST=$(git tag -l 'mobile-v*' | sed 's/mobile-v//' | sort -n | tail -1) + NEXT=$(( ${LAST:-0} + 1 )) + echo "next=$NEXT" >> "$GITHUB_OUTPUT" + echo "tag=mobile-v$NEXT" >> "$GITHUB_OUTPUT" + + - name: Stamp app version + run: node scripts/set-mobile-version.js ${{ steps.version.outputs.next }} + + - name: Restore Firebase config + run: echo "${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}" | base64 -d > google-services.json + + - name: Restore env + run: echo "EXPO_PUBLIC_API_URL=${{ secrets.EXPO_PUBLIC_API_URL }}" > .env + + - name: Restore signing keystore + run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > ksan.dev.keystore + + - name: Install dependencies + run: npm ci + - name: Prebuild Android project + run: npx expo prebuild -p android --no-install + + - name: Increase Gradle memory + run: | + echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> android/gradle.properties + + + - name: Build signed release APK + working-directory: frontend/android + run: | + ./gradlew assembleRelease --no-daemon \ + -Pandroid.injected.signing.store.file="$PWD/../ksan.dev.keystore" \ + -Pandroid.injected.signing.store.password="${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \ + -Pandroid.injected.signing.key.alias="${{ secrets.ANDROID_KEY_ALIAS }}" \ + -Pandroid.injected.signing.key.password="${{ secrets.ANDROID_KEY_PASSWORD }}" + + - name: Locate APK + id: apk + run: | + APK=$(find android/app/build/outputs/apk/release -name "*.apk" | head -n1) + OUT="etfoglasi-${{ steps.version.outputs.tag }}.apk" + cp "$APK" "$OUT" + echo "path=$OUT" >> "$GITHUB_OUTPUT" + echo "name=$OUT" >> "$GITHUB_OUTPUT" + + - name: Create Gitea release + run: | + API="https://git.${{ secrets.DOMAIN }}/api/v1/repos/${{ secrets.REGISTRY_USER }}/etf-oglasi" + AUTH="Authorization: token ${{ secrets.GITEARELEASES_TOKEN }}" + + HTTP_CODE=$(curl -s -o release.json -w "%{http_code}" -X POST \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${{ steps.version.outputs.tag }}\",\"target_commitish\":\"${{ github.sha }}\",\"name\":\"${{ steps.version.outputs.tag }}\",\"draft\":false,\"prerelease\":false}" \ + "$API/releases") + + echo "HTTP $HTTP_CODE" + cat release.json + + if [ "$HTTP_CODE" != "201" ]; then + echo "Failed to create release" + exit 1 + fi + + RELEASE_ID=$(node -pe "JSON.parse(require('fs').readFileSync('release.json','utf8')).id") + + HTTP_CODE=$(curl -s -o asset.json -w "%{http_code}" -X POST \ + -H "$AUTH" \ + -F "attachment=@${{ steps.apk.outputs.path }}" \ + "$API/releases/$RELEASE_ID/assets?name=${{ steps.apk.outputs.name }}") + + echo "HTTP $HTTP_CODE" + cat asset.json + + if [ "$HTTP_CODE" != "201" ]; then + echo "Failed to upload asset" + exit 1 + fi diff --git a/.gitignore b/.gitignore index f3e878f..cf70aec 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,8 @@ frontend/android/ # Expo frontend/expo-env.d.ts + + + +CLAUDE.md +ksan.dev.keystore diff --git a/README.md b/README.md index bd204b5..8841578 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,47 @@ -# Full-Stack Learning Project +# ETF Oglasi -This repository is a full-stack learning project focused on building and understanding modern application development practices end-to-end. +ETF Oglasi logo -It demonstrates how to design, build, and deploy a real-world application using a backend API, mobile frontend, database integration, and CI/CD workflows. +A hobby full-stack project I built to learn modern app development end to end. ---- +It scrapes the announcement boards of the Faculty of Electrical Engineering, +University of Banja Luka, stores the announcements in PostgreSQL, and lets +students browse and subscribe to subjects from a mobile app. Subscribers get +push notifications when new announcements are posted. ## Tech Stack -### Backend -- Spring Boot -- RESTful API architecture -- PostgreSQL database +- **Backend:** Spring Boot 3.5 (Java 21), REST API, PostgreSQL +- **Frontend:** React Native / Expo (Expo Router, NativeWind) +- **Notifications:** Firebase Cloud Messaging +- **Tooling:** Docker, Gitea Actions for CI/CD, deployed to a self-hosted home server -### Frontend -- React Native (Expo) +## Project Structure -### DevOps / Tooling -- Docker -- CI/CD pipelines (Gitea Actions or similar) -- Git version control +- `backend/` — Spring Boot REST API and the scraper +- `frontend/` — Expo mobile app ---- +## Backend -## Purpose +```bash +cd backend +cp src/main/resources/application.properties.example src/main/resources/application.properties # fill in datasource + jwt.secret +docker compose up -d # local Postgres +./gradlew bootRun +``` -The goal of this project is to gain hands-on experience with: +Run tests: `./gradlew test` -- Full-stack application architecture -- Backend API development with Spring Boot -- Mobile development with React Native -- Database design and integration -- Authentication and security concepts -- Containerization with Docker -- Continuous Integration / Continuous Deployment (CI/CD) +## Frontend ---- - -## Deployment - -The application is deployed on a self-hosted home server for development and testing purposes. - ---- - -## How to Run (Backend) - -To be added. - -Planned steps: -- Install Java and Gradle -- Configure PostgreSQL database -- Set environment variables -- Run Spring Boot application -- Expose API on localhost or network -- I usually run eveything with traefik but i dont know should i include that here??? - ---- - -## How to Run (Frontend) - -To be added... - - ---- - - -## CI/CD - -To be added. - -Planned steps: -- Configure Gitea Actions / CI pipeline -- Build backend and frontend automatically -- Deploy to home server -- Automate testing and builds - ---- +```bash +cd frontend +npm install +# set EXPO_PUBLIC_API_URL in .env to the backend base URL +npm start +``` ## Status -This project is currently under semi-active development as a learning and experimentation environment. - ---- - -## Notes - -This project is just me messing around with stuff while making an app i will use and perhaps others will find it useful +Semi-actively developed as a learning project. It is an app I actually use, and +maybe others will find it useful too. diff --git a/backend/init.sql b/backend/init.sql index 1715fe8..48f4736 100644 --- a/backend/init.sql +++ b/backend/init.sql @@ -50,7 +50,7 @@ CREATE TABLE IF NOT EXISTS public.users email character varying(255) COLLATE pg_catalog."default" NOT NULL, password character varying(255) COLLATE pg_catalog."default" NOT NULL, reg_time timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, - notification_type character varying(255) COLLATE pg_catalog."default" NOT NULL DEFAULT 'NO_NOTIFICATION'::character varying, + notification_type character varying(255) COLLATE pg_catalog."default" NOT NULL DEFAULT 'PUSH_NOTIFICATION'::character varying, CONSTRAINT users_pkey PRIMARY KEY (id), CONSTRAINT unique_email UNIQUE (email) ); diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/config/JwtFilter.java b/backend/src/main/java/dev/ksan/etfoglasiserver/config/JwtFilter.java index 76520ce..5e34840 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/config/JwtFilter.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/config/JwtFilter.java @@ -2,7 +2,7 @@ package dev.ksan.etfoglasiserver.config; import dev.ksan.etfoglasiserver.service.JWTService; import dev.ksan.etfoglasiserver.service.MyUserDetailsService; -import dev.ksan.etfoglasiserver.service.UserService; +import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -36,7 +36,12 @@ public class JwtFilter extends OncePerRequestFilter { if (authHeader != null && authHeader.startsWith("Bearer ")) { token = authHeader.substring(7); - username = jwtService.extractEmail(token); + try { + username = jwtService.extractEmail(token); + } catch (JwtException e) { + // expired, malformed, or otherwise invalid token - treat as unauthenticated + username = null; + } } if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/config/SecurityConfig.java b/backend/src/main/java/dev/ksan/etfoglasiserver/config/SecurityConfig.java index 14f94af..76844fd 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/config/SecurityConfig.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/config/SecurityConfig.java @@ -3,6 +3,7 @@ package dev.ksan.etfoglasiserver.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; @@ -16,6 +17,11 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration @EnableWebSecurity @@ -30,19 +36,39 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - return http.csrf(customizer -> customizer.disable()). - authorizeHttpRequests(request -> request + return http + .csrf(customizer -> customizer.disable()) + .cors(cors -> {}) + .authorizeHttpRequests(request -> request + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/api/login", "/api/register").permitAll() - .requestMatchers("/api/subjects/**", "/api/entries", "/api/groups").permitAll() - .anyRequest().authenticated()). - httpBasic(Customizer.withDefaults()). - sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .requestMatchers("/api/subjects/**", "/api/entries", "/api/groups").permitAll() + .anyRequest().authenticated() + ) + .httpBasic(Customizer.withDefaults()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) .build(); - - } + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOrigins(List.of( + "http://localhost:8081", + "https://etf-oglasi.ksan.dev" + )); + + config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return source; + } diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/controller/EntryController.java b/backend/src/main/java/dev/ksan/etfoglasiserver/controller/EntryController.java index f18cebc..210f569 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/controller/EntryController.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/controller/EntryController.java @@ -25,7 +25,7 @@ public class EntryController { return service.getEntryById(entryId); } - //ignore this for now + // TODO only for testing @PostMapping("/entries") public void addEntry(@RequestBody EntryDTO entry) { service.addEntry(entry); @@ -62,13 +62,14 @@ public class EntryController { } - @PutMapping("/entries") - public void updateEntry(@RequestBody EntryDTO entry) { - service.updateEntry(entry); - } +// @PutMapping("/entries") +// public void updateEntry(@RequestBody EntryDTO entry) { +// service.updateEntry(entry); +// } - @DeleteMapping("/entries/{entryId}") - public void deleteEntry(@PathVariable String entryId) { - service.deleteEntry(entryId); - } +// @DeleteMapping("/entries/{entryId}") +// public void deleteEntry(@PathVariable String entryId) { +// service.deleteEntry(entryId); +// } +// } diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java b/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java index 34a6898..744b59a 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/service/JWTService.java @@ -1,17 +1,13 @@ package dev.ksan.etfoglasiserver.service; -import dev.ksan.etfoglasiserver.model.User; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; -import java.security.NoSuchAlgorithmException; import java.util.*; import java.util.function.Function; -import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; @@ -29,7 +25,7 @@ public class JWTService { return Jwts.builder().claims().add(claims).subject(email) - .issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+60*60*300)) + .issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+1000L*60*60*24*60)) .and().signWith(getKey()).compact(); } diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java b/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java index d8809cb..25fc059 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java @@ -24,16 +24,30 @@ public class NotificationService { for (DeviceToken token : tokens) { + // Include a notification block so Android's system tray displays it + // even when the app is killed/dozing (a data-only message is only + // ever shown by the app's own JS handler, which won't run when the + // OS has force-stopped the app). HIGH priority tells FCM to deliver + // promptly and wake the device out of Doze. Message message = Message.builder() .setToken(token.getFcmToken()) + .setNotification(Notification.builder() + .setTitle(title) + .setBody(body) + .build()) + .setAndroidConfig(AndroidConfig.builder() + .setPriority(AndroidConfig.Priority.HIGH) + .build()) .putData("title", title) .putData("body", body) .build(); try { - firebaseMessaging.send(message); + String id = firebaseMessaging.send(message); + System.out.println("Send:" + id); } catch (FirebaseMessagingException ex) { + ex.printStackTrace(); if (ex.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED) { deviceTokenRepo.delete(token); diff --git a/backend/src/main/java/dev/ksan/etfoglasiserver/service/SubjectService.java b/backend/src/main/java/dev/ksan/etfoglasiserver/service/SubjectService.java index 96509fb..cf3bb99 100644 --- a/backend/src/main/java/dev/ksan/etfoglasiserver/service/SubjectService.java +++ b/backend/src/main/java/dev/ksan/etfoglasiserver/service/SubjectService.java @@ -74,6 +74,11 @@ public class SubjectService { @Async public void notifyAsync(Entry entry) { + // The scraper couldn't always match an announcement to a known subject, in + // which case there's nobody to notify. Guard against the NPE that would + // otherwise be thrown (and silently swallowed) on this async thread. + if (entry.getSubject() == null) return; + List users = userService.findUsersBySubjectId(entry.getSubject().getId()); diff --git a/backend/src/test/java/dev/ksan/etfoglasiserver/controller/EntryControllerTest.java b/backend/src/test/java/dev/ksan/etfoglasiserver/controller/EntryControllerTest.java index 5ecac6a..14b0303 100644 --- a/backend/src/test/java/dev/ksan/etfoglasiserver/controller/EntryControllerTest.java +++ b/backend/src/test/java/dev/ksan/etfoglasiserver/controller/EntryControllerTest.java @@ -139,14 +139,15 @@ class EntryControllerTest { } - @Test - @WithMockUser - void deleteEntry_authenticated_returns200() throws Exception { - doNothing().when(entryService).deleteEntry("entry-1"); - - mvc.perform(delete("/api/entries/entry-1")) - .andExpect(status().isOk()); - } + //we removed this from controller so test is pointless +// @Test +// @WithMockUser +// void deleteEntry_authenticated_returns200() throws Exception { +// doNothing().when(entryService).deleteEntry("entry-1"); +// +// mvc.perform(delete("/api/entries/entry-1")) +// .andExpect(status().isOk()); +// } @Test void deleteEntry_unauthenticated_returns401or403() throws Exception { diff --git a/ci-full.yaml b/ci-full.yaml deleted file mode 100644 index 8c644a7..0000000 --- a/ci-full.yaml +++ /dev/null @@ -1,24 +0,0 @@ -name: CI - full - -on: - pull_request: - branches: [main] - -jobs: - backend-test: - uses: ./.gitea/workflows/backend-test.yaml - - frontend-build: - runs-on: ubuntu-latest - defaults: - run: - working-directory: frontend - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install dependencies - run: npm ci - - name: Build frontend - run: npx expo export --platform web diff --git a/frontend/app.json b/frontend/app.json index 561014a..407b9f6 100644 --- a/frontend/app.json +++ b/frontend/app.json @@ -2,7 +2,7 @@ "expo": { "name": "etfoglasi", "slug": "etfoglasi", - "version": "1.0.0", + "version": "1.0.6", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "etfoglasi", @@ -14,7 +14,7 @@ "android": { "googleServicesFile": "./google-services.json", "adaptiveIcon": { - "backgroundColor": "#E6F4FE", + "backgroundColor": "#1C1918", "foregroundImage": "./assets/images/android-icon-foreground.png", "backgroundImage": "./assets/images/android-icon-background.png", "monochromeImage": "./assets/images/android-icon-monochrome.png" diff --git a/frontend/app/_layout.tsx b/frontend/app/_layout.tsx index 665b4f8..43b163b 100644 --- a/frontend/app/_layout.tsx +++ b/frontend/app/_layout.tsx @@ -1,5 +1,5 @@ import "../globals.css"; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Stack } from 'expo-router'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { @@ -10,8 +10,13 @@ import { } from '@react-native-firebase/messaging'; import { registerDeviceToken, listenForTokenRefresh } from '@/services/notifications'; import { AuthProvider, useAuth } from "@/context/AuthContext"; +import { useUpdatecheck } from '@/hooks/useUpdatecheck'; +import { UpdatePrompt } from '@/components/UpdatePrompt'; import Toast from 'react-native-toast-message'; +// The FCM background handler now lives in index.js (registered before this +// module loads) so it fires reliably while the app is backgrounded/killed. + function NotificationSetup() { const { user, loading } = useAuth(); @@ -27,7 +32,8 @@ function NotificationSetup() { console.log('Foreground notification:', msg); Toast.show({ type: 'info', - text1: msg.notification?.title ?? 'New Notification', + text1: (msg.data?.title as string | undefined) ?? 'New Notification', + text2: msg.data?.body as string | undefined, position: 'top', visibilityTime: 4000, }); @@ -50,12 +56,22 @@ function NotificationSetup() { return null; } +function UpdateCheck() { + const { updateInfo } = useUpdatecheck(); + const [dismissed, setDismissed] = useState(false); + + if (!updateInfo || dismissed) return null; + + return setDismissed(true)} />; +} + export default function RootLayout() { return ( + diff --git a/frontend/assets/images/android-icon-background.png b/frontend/assets/images/android-icon-background.png index 5ffefc5..ebf4f76 100644 Binary files a/frontend/assets/images/android-icon-background.png and b/frontend/assets/images/android-icon-background.png differ diff --git a/frontend/assets/images/android-icon-foreground.png b/frontend/assets/images/android-icon-foreground.png index 3a9e501..e68b6c4 100644 Binary files a/frontend/assets/images/android-icon-foreground.png and b/frontend/assets/images/android-icon-foreground.png differ diff --git a/frontend/assets/images/android-icon-monochrome.png b/frontend/assets/images/android-icon-monochrome.png index 77484eb..a338f38 100644 Binary files a/frontend/assets/images/android-icon-monochrome.png and b/frontend/assets/images/android-icon-monochrome.png differ diff --git a/frontend/assets/images/favicon.png b/frontend/assets/images/favicon.png index 408bd74..7208ce8 100644 Binary files a/frontend/assets/images/favicon.png and b/frontend/assets/images/favicon.png differ diff --git a/frontend/assets/images/icon.png b/frontend/assets/images/icon.png index 7165a53..746b22b 100644 Binary files a/frontend/assets/images/icon.png and b/frontend/assets/images/icon.png differ diff --git a/frontend/components/UpdatePrompt.tsx b/frontend/components/UpdatePrompt.tsx new file mode 100644 index 0000000..1305c5c --- /dev/null +++ b/frontend/components/UpdatePrompt.tsx @@ -0,0 +1,138 @@ +// components/UpdatePrompt.tsx +// ───────────────────────────────────────────────────────────────────────────── +// Modal that appears when a newer build is available. +// "Update now" opens the Gitea release page in the browser. +// "Later" dismisses it for the current session (it reappears next launch). +// ───────────────────────────────────────────────────────────────────────────── + +import React from 'react'; +import { + Linking, + Modal, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; +import type { UpdateInfo } from '../hooks/useUpdatecheck'; +import { version as currentVersion } from '../version.json'; + +interface Props { + updateInfo: UpdateInfo; + onDismiss: () => void; +} + +export function UpdatePrompt({ updateInfo, onDismiss }: Props) { + function openRelease() { + Linking.openURL(updateInfo.releaseUrl).catch(() => { + // If the device can't open the URL, just dismiss so the app isn't stuck. + onDismiss(); + }); + } + + return ( + + + + Update available + + + A new version of the app is available.{'\n'} + You are on build {currentVersion}, + the latest is build{' '} + {updateInfo.latestVersion}. + + + + + Later + + + + Update now + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + card: { + width: '100%', + maxWidth: 380, + backgroundColor: '#fff', + borderRadius: 12, + padding: 24, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 8, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: '#111', + marginBottom: 12, + }, + body: { + fontSize: 15, + color: '#444', + lineHeight: 22, + marginBottom: 24, + }, + bold: { + fontWeight: '700', + color: '#111', + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: 12, + }, + btn: { + paddingVertical: 10, + paddingHorizontal: 20, + borderRadius: 8, + }, + btnPrimary: { + backgroundColor: '#2563EB', + }, + btnPrimaryText: { + color: '#fff', + fontWeight: '600', + fontSize: 15, + }, + btnSecondary: { + backgroundColor: '#F3F4F6', + }, + btnSecondaryText: { + color: '#374151', + fontWeight: '600', + fontSize: 15, + }, +}); \ No newline at end of file diff --git a/frontend/config/gitea.tsx b/frontend/config/gitea.tsx new file mode 100644 index 0000000..ef56ecc --- /dev/null +++ b/frontend/config/gitea.tsx @@ -0,0 +1,10 @@ +export const GITEA_URL = 'https://git.ksan.dev'; + +export const GITEA_OWNER = 'ksan'; + +export const GITEA_REPO = 'etf-oglasi'; + + +export const RELEASE_TAG_PREFIX = 'mobile-v'; + +export const UPDATE_CHECK_TIMEOUT_MS = 8_000; diff --git a/frontend/context/AuthContext.tsx b/frontend/context/AuthContext.tsx index 7b40d73..15db829 100644 --- a/frontend/context/AuthContext.tsx +++ b/frontend/context/AuthContext.tsx @@ -1,9 +1,10 @@ import React, { createContext, useContext, useEffect, useState } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import Toast from "react-native-toast-message"; import { authApi, - LoginPayload, RegisterPayload, subscriptionsApi, userApi, UserUpdateDTO, + LoginPayload, RegisterPayload, setUnauthorizedHandler, subscriptionsApi, userApi, UserUpdateDTO, } from "@/services/api"; type User = { @@ -45,6 +46,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(u); }; + useEffect(() => { + setUnauthorizedHandler(() => { + persist(null); + Toast.show({ + type: 'info', + text1: 'Session expired', + text2: 'Please sign in again.', + position: 'top', + visibilityTime: 4000, + }); + }); + return () => setUnauthorizedHandler(null); + }, []); + const updateNotificationType = async (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => { if (!user) return; await userApi.updateNotificationType(type, user.token); diff --git a/frontend/hooks/useUpdatecheck.tsx b/frontend/hooks/useUpdatecheck.tsx new file mode 100644 index 0000000..72b4147 --- /dev/null +++ b/frontend/hooks/useUpdatecheck.tsx @@ -0,0 +1,137 @@ + +import { useEffect, useState } from 'react'; +import { + GITEA_URL, + GITEA_OWNER, + GITEA_REPO, + RELEASE_TAG_PREFIX, + UPDATE_CHECK_TIMEOUT_MS, +} from '../config/gitea'; +import { version as currentVersion } from '../version.json'; + +export interface UpdateInfo { + /** Build number of the latest release (same unit as currentVersion). */ + latestVersion: number; + /** Direct URL the user can open to read the release notes / download the APK. */ + releaseUrl: string; + /** Tag name as stored on Gitea, e.g. "mobile-v42". */ + tagName: string; +} + +export interface UseUpdateCheckResult { + /** True while the API call is in flight. */ + checking: boolean; + /** Populated once the check completes and a newer build exists. */ + updateInfo: UpdateInfo | null; + /** Non-null when the check failed (network error, timeout, bad response). */ + error: Error | null; + /** Re-run the check manually (e.g. from a "check again" button). */ + recheck: () => void; +} + +// ─── Gitea releases API ────────────────────────────────────────────────────── + +interface GiteaRelease { + id: number; + tag_name: string; + name: string; + html_url: string; + draft: boolean; + prerelease: boolean; +} + +async function fetchLatestRelease(): Promise { + const url = `${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/latest`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS); + + try { + const res = await fetch(url, { signal: controller.signal }); + + if (!res.ok) { + throw new Error(`Gitea API returned HTTP ${res.status}`); + } + + return (await res.json()) as GiteaRelease; + } finally { + clearTimeout(timer); + } +} + +/** Parse the integer build number out of a tag like "mobile-v42". Returns NaN if it can't. */ +function parseBuildNumber(tagName: string): number { + if (!tagName.startsWith(RELEASE_TAG_PREFIX)) return NaN; + return parseInt(tagName.slice(RELEASE_TAG_PREFIX.length), 10); +} + + +export function useUpdatecheck(): UseUpdateCheckResult { + const [checking, setChecking] = useState(false); + const [updateInfo, setUpdateInfo] = useState(null); + const [error, setError] = useState(null); + // A counter we bump to re-trigger the effect without changing the dep array shape. + const [trigger, setTrigger] = useState(0); + + useEffect(() => { + let cancelled = false; + + async function check() { + setChecking(true); + setError(null); + + try { + const release = await fetchLatestRelease(); + + if (cancelled) return; + + // Skip drafts and pre-releases. + if (release.draft || release.prerelease) { + setUpdateInfo(null); + return; + } + + const latestVersion = parseBuildNumber(release.tag_name); + + if (isNaN(latestVersion)) { + // The tag doesn't follow our scheme — ignore it silently. + setUpdateInfo(null); + return; + } + + if (latestVersion > currentVersion) { + setUpdateInfo({ + latestVersion, + releaseUrl: release.html_url, + tagName: release.tag_name, + }); + } else { + setUpdateInfo(null); + } + } catch (err) { + if (cancelled) return; + // Don't surface AbortError as a real error — it's just a timeout. + if (err instanceof Error && err.name === 'AbortError') { + setError(new Error('Update check timed out. Check your connection.')); + } else { + setError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + if (!cancelled) setChecking(false); + } + } + + check(); + + return () => { + cancelled = true; + }; + }, [trigger]); + + return { + checking, + updateInfo, + error, + recheck: () => setTrigger(t => t + 1), + }; +} \ No newline at end of file diff --git a/frontend/index.js b/frontend/index.js new file mode 100644 index 0000000..a5ea359 --- /dev/null +++ b/frontend/index.js @@ -0,0 +1,27 @@ +// Custom entry point. The FCM background handler must be registered in the very +// first module that loads — before the router/app mounts — so it reliably fires +// when the app is backgrounded or fully quit. Registering it here (rather than in +// app/_layout.tsx) is what react-native-firebase requires for the killed state. +import messaging from '@react-native-firebase/messaging'; +import notifee, { AndroidImportance } from '@notifee/react-native'; + +messaging().setBackgroundMessageHandler(async (remoteMessage) => { + const title = remoteMessage.data?.title; + const body = remoteMessage.data?.body; + if (!title && !body) return; + + const channelId = await notifee.createChannel({ + id: 'default', + name: 'General', + importance: AndroidImportance.HIGH, + }); + + await notifee.displayNotification({ + title, + body, + android: { channelId, pressAction: { id: 'default' } }, + }); +}); + +// Hand off to expo-router, which mounts app/_layout.tsx and the routes. +import 'expo-router/entry'; diff --git a/frontend/index.tsx b/frontend/index.tsx deleted file mode 100644 index c373b3d..0000000 --- a/frontend/index.tsx +++ /dev/null @@ -1,125 +0,0 @@ - - -import "./globals.css"; - -import React from "react"; -import { useColorScheme } from "react-native"; -import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native"; -import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; -import { Ionicons } from "@expo/vector-icons"; - -import SubscribedFeed from "@/screens/SubscribedFeed"; -import AllFeed from "@/screens/AllFeed"; -import Profile from "@/screens/Profile"; -import {enableScreens} from "react-native-screens"; -import {SafeAreaProvider} from "react-native-safe-area-context"; -import AuthGate from "@/screens/AuthGate"; -import {AuthProvider, useAuth} from "@/context/AuthContext"; -import messaging, {setBackgroundMessageHandler} from "@react-native-firebase/messaging"; -import {getMessaging} from "@firebase/messaging"; -messaging().setBackgroundMessageHandler(async () => {}); -// Must be called before any navigator renders -enableScreens(); - -const Tab = createBottomTabNavigator(); - -const LIGHT_TAB = { - bg: "#FFFFFF", - border: "#E8E2D5", - active: "#C4622D", - inactive: "#8A8278", - label: "#1A1714", -}; -const DARK_TAB = { - bg: "#161513", - border: "#2C2A27", - active: "#E07B45", - inactive: "#706D67", - label: "#F0EDE8", -}; - - -function ProfileTab() { - const { user, loading } = useAuth(); - if (loading) return null; - return user ? : ; -} - -export default function App() { - const scheme = useColorScheme(); - const dark = scheme === "dark"; - const tab = dark ? DARK_TAB : LIGHT_TAB; - - const navTheme = dark - ? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } } - : { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } }; - - return ( - - - - ({ - headerShown: false, - - tabBarStyle: { - backgroundColor: tab.bg, - borderTopColor: tab.border, - borderTopWidth: 1, - height: 60, - paddingBottom: 8, - paddingTop: 6, - }, - - tabBarActiveTintColor: tab.active, - tabBarInactiveTintColor: tab.inactive, - - tabBarLabelStyle: { - fontSize: 10, - fontWeight: "600", - letterSpacing: 0.3, - }, - - tabBarIcon: ({ focused, color, size }) => { - const icons: Record< - string, - { active: keyof typeof Ionicons.glyphMap; inactive: keyof typeof Ionicons.glyphMap } - > = { - "Subscribed": { active: "bookmark", inactive: "bookmark-outline" }, - "Discover": { active: "compass", inactive: "compass-outline" }, - "Profile": { active: "person-circle", inactive: "person-circle-outline" }, - }; - const set = icons[route.name]; - - return ( - - ); - }, - })} - > - - - - - - - - - ); -} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 512fa3c..cf93d01 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@expo/vector-icons": "^15.0.3", + "@notifee/react-native": "^9.1.8", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-firebase/app": "^24.0.0", "@react-native-firebase/messaging": "^24.0.0", @@ -3583,6 +3584,15 @@ "node": ">=12.4.0" } }, + "node_modules/@notifee/react-native": { + "version": "9.1.8", + "resolved": "https://registry.npmjs.org/@notifee/react-native/-/react-native-9.1.8.tgz", + "integrity": "sha512-Az/dueoPerJsbbjRxu8a558wKY+gONUrfoy3Hs++5OqbeMsR0dYe6P+4oN6twrLFyzAhEA1tEoZRvQTFDRmvQg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index c3e52cf..4958cbe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "etfoglasi", - "main": "expo-router/entry", + "main": "index.js", "version": "1.0.0", "scripts": { "start": "expo start", @@ -12,6 +12,7 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.3", + "@notifee/react-native": "^9.1.8", "@react-native-async-storage/async-storage": "2.2.0", "@react-native-firebase/app": "^24.0.0", "@react-native-firebase/messaging": "^24.0.0", diff --git a/frontend/screens/HelpSupport.tsx b/frontend/screens/HelpSupport.tsx index 6b73ba8..4d81cfd 100644 --- a/frontend/screens/HelpSupport.tsx +++ b/frontend/screens/HelpSupport.tsx @@ -5,11 +5,14 @@ import { } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Ionicons } from "@expo/vector-icons"; +import Constants from "expo-constants"; const CONTACT_EMAIL = "contact@ksan.dev"; //TODO make github page because yea const GITHUB_URL = "https://git.ksan.dev/ksan"; -const APP_VERSION = "1.0.0"; +// Sourced from app.json's expo.version, which CI stamps on every mobile +// release (scripts/set-mobile-version.js), so this stays in sync automatically. +const APP_VERSION = Constants.expoConfig?.version ?? "unknown"; export default function HelpSupport({ onClose }: { onClose: () => void }) { const dark = useColorScheme() === "dark"; diff --git a/frontend/scripts/set-mobile-version.js b/frontend/scripts/set-mobile-version.js new file mode 100644 index 0000000..e0528fe --- /dev/null +++ b/frontend/scripts/set-mobile-version.js @@ -0,0 +1,28 @@ +// Used by CI to stamp a mobile release build with its version number. +// Updates version.json (read by hooks/useUpdatecheck.tsx) and app.json's +// android.versionCode/versionName so the build, the in-app update check, +// and the Gitea release tag (mobile-vN) all agree on the same number. +const fs = require('fs'); +const path = require('path'); + +const version = parseInt(process.argv[2], 10); +if (!Number.isInteger(version) || version <= 0) { + console.error('Usage: node set-mobile-version.js '); + process.exit(1); +} + +const root = path.join(__dirname, '..'); + +fs.writeFileSync( + path.join(root, 'version.json'), + JSON.stringify({ version }, null, 2) + '\n', +); + +const appJsonPath = path.join(root, 'app.json'); +const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf8')); +appJson.expo.version = `1.0.${version}`; +appJson.expo.android = appJson.expo.android ?? {}; +appJson.expo.android.versionCode = version; +fs.writeFileSync(appJsonPath, JSON.stringify(appJson, null, 2) + '\n'); + +console.log(`Stamped mobile version ${version}`); diff --git a/frontend/services/api.tsx b/frontend/services/api.tsx index 117e593..63d3d2f 100644 --- a/frontend/services/api.tsx +++ b/frontend/services/api.tsx @@ -1,11 +1,19 @@ -const BASE_URL = process.env.EXPO_PUBLIC_API_URL; // TODO change this +const BASE_URL = process.env.EXPO_PUBLIC_API_URL+'/api'; export type NotificationType = 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION'; function authHeader(token?: string) { return token ? { Authorization: `Bearer ${token}` } : {}; } +let onUnauthorized: (() => void) | null = null; + +// Called once from AuthProvider so api.tsx can trigger a logout/relogin +// when the backend rejects a request due to a missing/expired/invalid token. +export function setUnauthorizedHandler(handler: (() => void) | null) { + onUnauthorized = handler; +} + async function request( method: string, path: string, @@ -30,6 +38,7 @@ async function request( console.log('Failed URL:', res.url); console.log('Status:', res.status); console.log('Response:', errorText); + if (res.status === 401 && token) onUnauthorized?.(); throw new Error(errorText); } return res.json(); diff --git a/frontend/services/notifications.tsx b/frontend/services/notifications.tsx index db6d4ad..ef4b6bf 100644 --- a/frontend/services/notifications.tsx +++ b/frontend/services/notifications.tsx @@ -5,6 +5,7 @@ import { onTokenRefresh, AuthorizationStatus, } from '@react-native-firebase/messaging'; +import notifee, { AndroidImportance } from '@notifee/react-native'; import { post } from '@/services/api'; export async function registerDeviceToken(authToken: string): Promise { @@ -18,7 +19,7 @@ export async function registerDeviceToken(authToken: string): Promise { const fcmToken = await getToken(messaging); - await fetch(`${process.env.EXPO_PUBLIC_API_URL}/device-tokens/register`, { + await fetch(`${process.env.EXPO_PUBLIC_API_URL}/api/device-tokens/register`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -32,4 +33,23 @@ export function listenForTokenRefresh(authToken: string): () => void { return onTokenRefresh(getMessaging(), async (newToken) => { await post('/device-tokens/register', { token: newToken }, authToken); }); +} + +export async function displayLocalNotification(title?: string, body?: string) { + if (!title && !body) return; + + const channelId = await notifee.createChannel({ + id: 'default', + name: 'General', + importance: AndroidImportance.HIGH, + }); + + await notifee.displayNotification({ + title, + body, + android: { + channelId, + pressAction: { id: 'default' }, + }, + }); } \ No newline at end of file diff --git a/frontend/version.json b/frontend/version.json new file mode 100644 index 0000000..0e33e3f --- /dev/null +++ b/frontend/version.json @@ -0,0 +1,3 @@ +{ + "version": 0 +} \ No newline at end of file diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..9555b3d Binary files /dev/null and b/logo.png differ