From 45e57181a9f0ef6ffb0bbe987358cdbd0e91cb3d Mon Sep 17 00:00:00 2001 From: Ksan Date: Thu, 30 Jul 2026 15:28:12 +0200 Subject: [PATCH] fix push notification delivery on android --- backend/init.sql | 2 +- .../service/NotificationService.java | 12 ++ .../service/SubjectService.java | 5 + frontend/app/_layout.tsx | 12 +- frontend/hooks/usePushNotifications.tsx | 46 ----- frontend/index.js | 27 +++ frontend/index.tsx | 166 ------------------ frontend/package.json | 2 +- 8 files changed, 49 insertions(+), 223 deletions(-) delete mode 100644 frontend/hooks/usePushNotifications.tsx create mode 100644 frontend/index.js delete mode 100644 frontend/index.tsx 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/service/NotificationService.java b/backend/src/main/java/dev/ksan/etfoglasiserver/service/NotificationService.java index 2715cf4..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,8 +24,20 @@ 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(); 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/frontend/app/_layout.tsx b/frontend/app/_layout.tsx index 67339a0..43b163b 100644 --- a/frontend/app/_layout.tsx +++ b/frontend/app/_layout.tsx @@ -7,21 +7,15 @@ import { onMessage, onNotificationOpenedApp, getInitialNotification, - setBackgroundMessageHandler, } from '@react-native-firebase/messaging'; -import { registerDeviceToken, listenForTokenRefresh, displayLocalNotification } from '@/services/notifications'; +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'; -// 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); -}); +// 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(); diff --git a/frontend/hooks/usePushNotifications.tsx b/frontend/hooks/usePushNotifications.tsx deleted file mode 100644 index 43415f3..0000000 --- a/frontend/hooks/usePushNotifications.tsx +++ /dev/null @@ -1,46 +0,0 @@ - -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(); - }; - }, []); -} 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 b8b9687..0000000 --- a/frontend/index.tsx +++ /dev/null @@ -1,166 +0,0 @@ - - -import "./globals.css"; - -import React, { useState } 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 { 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 AllFeed from "@/screens/AllFeed"; -import Profile from "@/screens/Profile"; -import AuthGate from "@/screens/AuthGate"; -import { AuthProvider, useAuth } from "@/context/AuthContext"; -import { useUpdatecheck } from "./hooks/useUpdatecheck"; -import { UpdatePrompt } from "./components/UpdatePrompt"; -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 -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" } }; - - const { updateInfo } = useUpdatecheck(); - const [updateDismissed, setUpdateDismissed] = useState(false); - - usePushNotifications(); - - 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 ( - - ); - }, - })} - > - - - - - - - {/* Overlays the entire app including the tab bar */} - {updateInfo && !updateDismissed && ( - setUpdateDismissed(true)} - /> - )} - - - - ); -} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 1bc0660..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",