13 Commits

Author SHA1 Message Date
ksan 0aa633a483 added update check
CI/CD / Backend Unit Tests (push) Successful in 2m29s
CI/CD / Deploy (push) Successful in 2m16s
2026-06-11 14:22:08 +02:00
ksan 8404bcafd0 removed test that was not needed
CI/CD / Backend Unit Tests (push) Successful in 2m14s
CI/CD / Deploy (push) Successful in 2m10s
2026-06-11 14:01:16 +02:00
ksan e3529f892c final touches
CI/CD / Deploy (push) Has been cancelled
CI/CD / Backend Unit Tests (push) Has been cancelled
2026-06-11 13:54:10 +02:00
ksan 7d7e641f5b fixed push notifications and auth modified
CI/CD / Backend Unit Tests (push) Successful in 2m17s
CI/CD / Deploy (push) Successful in 2m26s
2026-06-11 13:30:36 +02:00
ksan 3afe5e2931 switching branch 2026-06-11 11:54:14 +02:00
ksan 14725e180a push notifications not working for some reason
CI/CD / Backend Unit Tests (push) Successful in 1m48s
CI/CD / Deploy (push) Successful in 1m38s
2026-06-11 01:29:48 +02:00
ksan 30d2abd4c5 fixing cors issue and ci/cd
CI/CD / Backend Unit Tests (push) Successful in 1m52s
CI/CD / Deploy (push) Successful in 1m46s
2026-06-10 22:54:00 +02:00
ksan ee4eff34c9 testing if this will fix this
CI/CD / Backend Unit Tests (push) Successful in 1m44s
CI/CD / Deploy (push) Successful in 1m46s
2026-06-10 19:42:31 +02:00
ksan 62a3b11f43 moved from echo to printf
CI/CD / Backend Unit Tests (push) Successful in 1m46s
CI/CD / Deploy (push) Successful in 1m50s
2026-06-10 19:30:10 +02:00
ksan 6f941e7842 fixing missing creds
CI/CD / Backend Unit Tests (push) Successful in 2m3s
CI/CD / Deploy (push) Failing after 1m49s
2026-06-10 19:22:10 +02:00
ksan 8db2ecfefe removed excess
CI/CD / Backend Unit Tests (push) Successful in 1m45s
CI/CD / Deploy (push) Successful in 1m56s
2026-06-10 18:41:54 +02:00
ksan 4621c0d5ca fixing issue with firebase on deploy 2026-06-10 18:41:54 +02:00
ksan 35c40791e6 Merge pull request 'Dev' (#1) from dev into main
CI/CD / Backend Unit Tests (push) Successful in 1m43s
CI/CD / Deploy (push) Successful in 1m59s
Reviewed-on: #1
testiranje
2026-06-10 16:12:21 +00:00
23 changed files with 619 additions and 169 deletions
+5 -27
View File
@@ -1,91 +1,71 @@
name: CI/CD name: CI/CD
on: on:
push: push:
branches: branches:
- dev - dev
- main - main
pull_request: pull_request:
branches: branches:
- main - main
env: env:
JAVA_VERSION: "21" JAVA_VERSION: "21"
jobs: jobs:
backend-unit-tests: backend-unit-tests:
name: Backend Unit Tests name: Backend Unit Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
working-directory: backend working-directory: backend
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up JDK ${{ env.JAVA_VERSION }} - name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: ${{ env.JAVA_VERSION }} java-version: ${{ env.JAVA_VERSION }}
- name: Make Gradle wrapper executable - name: Make Gradle wrapper executable
run: chmod +x gradlew run: chmod +x gradlew
- name: Run unit tests - name: Run unit tests
run: ./gradlew test run: ./gradlew test
deploy: deploy:
name: Deploy name: Deploy
needs: backend-unit-tests needs: backend-unit-tests
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up JDK ${{ env.JAVA_VERSION }} - name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: ${{ env.JAVA_VERSION }} java-version: ${{ env.JAVA_VERSION }}
- name: Build boot jar - name: Build boot jar
working-directory: backend working-directory: backend
run: | run: |
chmod +x gradlew chmod +x gradlew
./gradlew bootJar ./gradlew bootJar
- name: Stage jar for Docker - name: Stage jar for Docker
working-directory: backend working-directory: backend
run: | run: |
BOOT_JAR=$(find build/libs -name "*.jar" ! -name "*-plain.jar" | head -n 1) BOOT_JAR=$(find build/libs -name "*.jar" ! -name "*-plain.jar" | head -n 1)
cp "$BOOT_JAR" app.jar cp "$BOOT_JAR" app.jar
- name: Login to Docker registry - name: Login to Docker registry
run: | run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \ echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.${{ secrets.DOMAIN }} \ docker login git.${{ secrets.DOMAIN }} \
-u "${{ secrets.REGISTRY_USER }}" \ -u "${{ secrets.REGISTRY_USER }}" \
--password-stdin --password-stdin
- name: Build Docker image - name: Build Docker image
run: | run: |
docker build \ docker build \
-t git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest \ -t git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest \
backend/ backend/
- name: Push Docker image - name: Push Docker image
run: | run: |
docker push \ docker push \
git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest
- name: Deploy to VPS - name: Deploy to VPS
run: | run: |
mkdir -p ~/.ssh mkdir -p ~/.ssh
@@ -93,14 +73,12 @@ jobs:
chmod 600 ~/.ssh/key chmod 600 ~/.ssh/key
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts 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" "mkdir -p ~/programs/etf-oglasi-server/config"
echo "${{ secrets.FIREBASE_CREDENTIALS }}" | \ ssh -i ~/.ssh/key \
ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"cat > ~/programs/etf-oglasi-server/config/firebase.json"
ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"cd ~/programs/etf-oglasi-server && docker compose pull && docker compose up -d" "cd ~/programs/etf-oglasi-server && docker compose pull && docker compose up -d"
+5
View File
@@ -87,3 +87,8 @@ frontend/android/
# Expo # Expo
frontend/expo-env.d.ts frontend/expo-env.d.ts
CLAUDE.md
ksan.dev.keystore
+3
View File
@@ -86,3 +86,6 @@ This project is currently under semi-active development as a learning and experi
## Notes ## Notes
This project is just me messing around with stuff while making an app i will use and perhaps others will find it useful This project is just me messing around with stuff while making an app i will use and perhaps others will find it useful
need to made .creds .env and init directory with subjects.txt
@@ -4,20 +4,25 @@ import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions; import com.google.firebase.FirebaseOptions;
import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.FirebaseMessaging;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
@Configuration @Configuration
public class FirebaseConfig { public class FirebaseConfig {
@Value("${firebase.credentials.path}")
private String credentialsPath;
@Bean @Bean
public FirebaseApp firebaseApp() throws IOException { public FirebaseApp firebaseApp() throws IOException {
GoogleCredentials credentials = GoogleCredentials GoogleCredentials credentials = GoogleCredentials
.fromStream(new ClassPathResource("firebase.json").getInputStream()) .fromStream(new FileInputStream(credentialsPath))
.createScoped(List.of("https://www.googleapis.com/auth/firebase.messaging")); .createScoped(List.of("https://www.googleapis.com/auth/firebase.messaging"));
FirebaseOptions options = FirebaseOptions.builder() FirebaseOptions options = FirebaseOptions.builder()
@@ -2,7 +2,7 @@ package dev.ksan.etfoglasiserver.config;
import dev.ksan.etfoglasiserver.service.JWTService; import dev.ksan.etfoglasiserver.service.JWTService;
import dev.ksan.etfoglasiserver.service.MyUserDetailsService; import dev.ksan.etfoglasiserver.service.MyUserDetailsService;
import dev.ksan.etfoglasiserver.service.UserService; import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@@ -36,7 +36,12 @@ public class JwtFilter extends OncePerRequestFilter {
if (authHeader != null && authHeader.startsWith("Bearer ")) { if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7); token = authHeader.substring(7);
try {
username = jwtService.extractEmail(token); 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) { if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
@@ -3,6 +3,7 @@ package dev.ksan.etfoglasiserver.config;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider; 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.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 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 @Configuration
@EnableWebSecurity @EnableWebSecurity
@@ -30,19 +36,39 @@ public class SecurityConfig {
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http.csrf(customizer -> customizer.disable()). return http
authorizeHttpRequests(request -> request .csrf(customizer -> customizer.disable())
.cors(cors -> {})
.authorizeHttpRequests(request -> request
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/login", "/api/register").permitAll() .requestMatchers("/api/login", "/api/register").permitAll()
.requestMatchers("/api/subjects/**", "/api/entries", "/api/groups").permitAll() .requestMatchers("/api/subjects/**", "/api/entries", "/api/groups").permitAll()
.anyRequest().authenticated()). .anyRequest().authenticated()
httpBasic(Customizer.withDefaults()). )
sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .httpBasic(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build(); .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;
}
@@ -25,7 +25,7 @@ public class EntryController {
return service.getEntryById(entryId); return service.getEntryById(entryId);
} }
//ignore this for now // TODO only for testing
@PostMapping("/entries") @PostMapping("/entries")
public void addEntry(@RequestBody EntryDTO entry) { public void addEntry(@RequestBody EntryDTO entry) {
service.addEntry(entry); service.addEntry(entry);
@@ -62,13 +62,14 @@ public class EntryController {
} }
@PutMapping("/entries") // @PutMapping("/entries")
public void updateEntry(@RequestBody EntryDTO entry) { // public void updateEntry(@RequestBody EntryDTO entry) {
service.updateEntry(entry); // service.updateEntry(entry);
} // }
@DeleteMapping("/entries/{entryId}") // @DeleteMapping("/entries/{entryId}")
public void deleteEntry(@PathVariable String entryId) { // public void deleteEntry(@PathVariable String entryId) {
service.deleteEntry(entryId); // service.deleteEntry(entryId);
} // }
//
} }
@@ -1,17 +1,13 @@
package dev.ksan.etfoglasiserver.service; package dev.ksan.etfoglasiserver.service;
import dev.ksan.etfoglasiserver.model.User;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import java.security.NoSuchAlgorithmException;
import java.util.*; import java.util.*;
import java.util.function.Function; import java.util.function.Function;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -29,7 +25,7 @@ public class JWTService {
return Jwts.builder().claims().add(claims).subject(email) 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(); .and().signWith(getKey()).compact();
} }
@@ -31,9 +31,11 @@ public class NotificationService {
.build(); .build();
try { try {
firebaseMessaging.send(message); String id = firebaseMessaging.send(message);
System.out.println("Send:" + id);
} catch (FirebaseMessagingException ex) { } catch (FirebaseMessagingException ex) {
ex.printStackTrace();
if (ex.getMessagingErrorCode() if (ex.getMessagingErrorCode()
== MessagingErrorCode.UNREGISTERED) { == MessagingErrorCode.UNREGISTERED) {
deviceTokenRepo.delete(token); deviceTokenRepo.delete(token);
@@ -139,14 +139,15 @@ class EntryControllerTest {
} }
@Test //we removed this from controller so test is pointless
@WithMockUser // @Test
void deleteEntry_authenticated_returns200() throws Exception { // @WithMockUser
doNothing().when(entryService).deleteEntry("entry-1"); // void deleteEntry_authenticated_returns200() throws Exception {
// doNothing().when(entryService).deleteEntry("entry-1");
mvc.perform(delete("/api/entries/entry-1")) //
.andExpect(status().isOk()); // mvc.perform(delete("/api/entries/entry-1"))
} // .andExpect(status().isOk());
// }
@Test @Test
void deleteEntry_unauthenticated_returns401or403() throws Exception { void deleteEntry_unauthenticated_returns401or403() throws Exception {
-24
View File
@@ -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
+25 -3
View File
@@ -1,5 +1,5 @@
import "../globals.css"; import "../globals.css";
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import { import {
@@ -7,11 +7,22 @@ import {
onMessage, onMessage,
onNotificationOpenedApp, onNotificationOpenedApp,
getInitialNotification, getInitialNotification,
setBackgroundMessageHandler,
} from '@react-native-firebase/messaging'; } from '@react-native-firebase/messaging';
import { registerDeviceToken, listenForTokenRefresh } from '@/services/notifications'; import { registerDeviceToken, listenForTokenRefresh, displayLocalNotification } from '@/services/notifications';
import { AuthProvider, useAuth } from "@/context/AuthContext"; import { AuthProvider, useAuth } from "@/context/AuthContext";
import { useUpdatecheck } from '@/hooks/useUpdatecheck';
import { UpdatePrompt } from '@/components/UpdatePrompt';
import Toast from 'react-native-toast-message'; import Toast from 'react-native-toast-message';
// Registered at module scope so it's installed as soon as this entry file
// loads, which is required for it to fire while the app is backgrounded/killed.
setBackgroundMessageHandler(getMessaging(), async (remoteMessage) => {
const title = remoteMessage.data?.title as string | undefined;
const body = remoteMessage.data?.body as string | undefined;
await displayLocalNotification(title, body);
});
function NotificationSetup() { function NotificationSetup() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -27,7 +38,8 @@ function NotificationSetup() {
console.log('Foreground notification:', msg); console.log('Foreground notification:', msg);
Toast.show({ Toast.show({
type: 'info', 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', position: 'top',
visibilityTime: 4000, visibilityTime: 4000,
}); });
@@ -50,12 +62,22 @@ function NotificationSetup() {
return null; return null;
} }
function UpdateCheck() {
const { updateInfo } = useUpdatecheck();
const [dismissed, setDismissed] = useState(false);
if (!updateInfo || dismissed) return null;
return <UpdatePrompt updateInfo={updateInfo} onDismiss={() => setDismissed(true)} />;
}
export default function RootLayout() { export default function RootLayout() {
return ( return (
<AuthProvider> <AuthProvider>
<SafeAreaProvider> <SafeAreaProvider>
<NotificationSetup /> <NotificationSetup />
<Stack screenOptions={{ headerShown: false }} /> <Stack screenOptions={{ headerShown: false }} />
<UpdateCheck />
<Toast /> <Toast />
</SafeAreaProvider> </SafeAreaProvider>
</AuthProvider> </AuthProvider>
+138
View File
@@ -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 (
<Modal
visible
transparent
animationType="fade"
onRequestClose={onDismiss}
>
<View style={styles.overlay}>
<View style={styles.card}>
<Text style={styles.title}>Update available</Text>
<Text style={styles.body}>
A new version of the app is available.{'\n'}
You are on build <Text style={styles.bold}>{currentVersion}</Text>,
the latest is build{' '}
<Text style={styles.bold}>{updateInfo.latestVersion}</Text>.
</Text>
<View style={styles.actions}>
<Pressable
style={[styles.btn, styles.btnSecondary]}
onPress={onDismiss}
accessibilityRole="button"
accessibilityLabel="Remind me later"
>
<Text style={styles.btnSecondaryText}>Later</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.btnPrimary]}
onPress={openRelease}
accessibilityRole="button"
accessibilityLabel="Open the release page to update"
>
<Text style={styles.btnPrimaryText}>Update now</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
);
}
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,
},
});
+10
View File
@@ -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;
+16 -1
View File
@@ -1,9 +1,10 @@
import React, { createContext, useContext, useEffect, useState } from "react"; import React, { createContext, useContext, useEffect, useState } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import Toast from "react-native-toast-message";
import { import {
authApi, authApi,
LoginPayload, RegisterPayload, subscriptionsApi, userApi, UserUpdateDTO, LoginPayload, RegisterPayload, setUnauthorizedHandler, subscriptionsApi, userApi, UserUpdateDTO,
} from "@/services/api"; } from "@/services/api";
type User = { type User = {
@@ -45,6 +46,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(u); 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') => { const updateNotificationType = async (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => {
if (!user) return; if (!user) return;
await userApi.updateNotificationType(type, user.token); await userApi.updateNotificationType(type, user.token);
+46
View File
@@ -0,0 +1,46 @@
import { useEffect } from "react";
import messaging from "@react-native-firebase/messaging";
import notifee, { AndroidImportance, EventType } from "@notifee/react-native";
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" },
},
});
}
export function usePushNotifications() {
useEffect(() => {
const unsubscribeMessage = messaging().onMessage(async remoteMessage => {
const title = remoteMessage.data?.title as string | undefined;
const body = remoteMessage.data?.body as string | undefined;
await displayLocalNotification(title, body);
});
const unsubscribeNotifee = notifee.onForegroundEvent(({ type, detail }) => {
if (type === EventType.PRESS) {
console.log("Notification tapped:", detail.notification);
}
});
return () => {
unsubscribeMessage();
unsubscribeNotifee();
};
}, []);
}
+137
View File
@@ -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<GiteaRelease> {
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<UpdateInfo | null>(null);
const [error, setError] = useState<Error | null>(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),
};
}
+49 -8
View File
@@ -2,25 +2,52 @@
import "./globals.css"; import "./globals.css";
import React from "react"; import React, { useState } from "react";
import { useColorScheme } from "react-native"; import { useColorScheme } from "react-native";
import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native"; import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { enableScreens } from "react-native-screens";
import { SafeAreaProvider } from "react-native-safe-area-context";
import messaging from "@react-native-firebase/messaging";
import notifee, { AndroidImportance } from "@notifee/react-native";
import SubscribedFeed from "@/screens/SubscribedFeed"; import SubscribedFeed from "@/screens/SubscribedFeed";
import AllFeed from "@/screens/AllFeed"; import AllFeed from "@/screens/AllFeed";
import Profile from "@/screens/Profile"; 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 AuthGate from "@/screens/AuthGate";
import { AuthProvider, useAuth } from "@/context/AuthContext"; import { AuthProvider, useAuth } from "@/context/AuthContext";
import messaging, {setBackgroundMessageHandler} from "@react-native-firebase/messaging"; import { useUpdatecheck } from "./hooks/useUpdatecheck";
import {getMessaging} from "@firebase/messaging"; import { UpdatePrompt } from "./components/UpdatePrompt";
messaging().setBackgroundMessageHandler(async () => {}); import { usePushNotifications } from "./hooks/usePushNotifications";
// Handles data-only FCM messages when the app is in the background or closed.
messaging().setBackgroundMessageHandler(async remoteMessage => {
const title = remoteMessage.data?.title as string | undefined;
const body = remoteMessage.data?.body as string | undefined;
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" },
},
});
});
// Must be called before any navigator renders // Must be called before any navigator renders
enableScreens(); enableScreens();
const Tab = createBottomTabNavigator(); const Tab = createBottomTabNavigator();
const LIGHT_TAB = { const LIGHT_TAB = {
@@ -30,6 +57,7 @@ const LIGHT_TAB = {
inactive: "#8A8278", inactive: "#8A8278",
label: "#1A1714", label: "#1A1714",
}; };
const DARK_TAB = { const DARK_TAB = {
bg: "#161513", bg: "#161513",
border: "#2C2A27", border: "#2C2A27",
@@ -38,13 +66,13 @@ const DARK_TAB = {
label: "#F0EDE8", label: "#F0EDE8",
}; };
function ProfileTab() { function ProfileTab() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
if (loading) return null; if (loading) return null;
return user ? <Profile /> : <AuthGate />; return user ? <Profile /> : <AuthGate />;
} }
export default function App() { export default function App() {
const scheme = useColorScheme(); const scheme = useColorScheme();
const dark = scheme === "dark"; const dark = scheme === "dark";
@@ -54,6 +82,11 @@ export default function App() {
? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } } ? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } }
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } }; : { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
const { updateInfo } = useUpdatecheck();
const [updateDismissed, setUpdateDismissed] = useState(false);
usePushNotifications();
return ( return (
<AuthProvider> <AuthProvider>
<SafeAreaProvider> <SafeAreaProvider>
@@ -118,8 +151,16 @@ export default function App() {
/> />
</Tab.Navigator> </Tab.Navigator>
</NavigationContainer> </NavigationContainer>
</SafeAreaProvider>
{/* Overlays the entire app including the tab bar */}
{updateInfo && !updateDismissed && (
<UpdatePrompt
updateInfo={updateInfo}
onDismiss={() => setUpdateDismissed(true)}
/>
)}
</SafeAreaProvider>
</AuthProvider> </AuthProvider>
); );
} }
+10
View File
@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@notifee/react-native": "^9.1.8",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-native-firebase/app": "^24.0.0", "@react-native-firebase/app": "^24.0.0",
"@react-native-firebase/messaging": "^24.0.0", "@react-native-firebase/messaging": "^24.0.0",
@@ -3583,6 +3584,15 @@
"node": ">=12.4.0" "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": { "node_modules/@protobufjs/aspromise": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+1
View File
@@ -12,6 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@notifee/react-native": "^9.1.8",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-native-firebase/app": "^24.0.0", "@react-native-firebase/app": "^24.0.0",
"@react-native-firebase/messaging": "^24.0.0", "@react-native-firebase/messaging": "^24.0.0",
+10 -1
View File
@@ -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'; export type NotificationType = 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
function authHeader(token?: string) { function authHeader(token?: string) {
return token ? { Authorization: `Bearer ${token}` } : {}; 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<T>( async function request<T>(
method: string, method: string,
path: string, path: string,
@@ -30,6 +38,7 @@ async function request<T>(
console.log('Failed URL:', res.url); console.log('Failed URL:', res.url);
console.log('Status:', res.status); console.log('Status:', res.status);
console.log('Response:', errorText); console.log('Response:', errorText);
if (res.status === 401 && token) onUnauthorized?.();
throw new Error(errorText); throw new Error(errorText);
} }
return res.json(); return res.json();
+21 -1
View File
@@ -5,6 +5,7 @@ import {
onTokenRefresh, onTokenRefresh,
AuthorizationStatus, AuthorizationStatus,
} from '@react-native-firebase/messaging'; } from '@react-native-firebase/messaging';
import notifee, { AndroidImportance } from '@notifee/react-native';
import { post } from '@/services/api'; import { post } from '@/services/api';
export async function registerDeviceToken(authToken: string): Promise<void> { export async function registerDeviceToken(authToken: string): Promise<void> {
@@ -18,7 +19,7 @@ export async function registerDeviceToken(authToken: string): Promise<void> {
const fcmToken = await getToken(messaging); 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -33,3 +34,22 @@ export function listenForTokenRefresh(authToken: string): () => void {
await post('/device-tokens/register', { token: newToken }, authToken); 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' },
},
});
}
+3
View File
@@ -0,0 +1,3 @@
{
"version": 0
}