diff --git a/README.md b/README.md index ea0cc3f..d4f4341 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ bigbrainparking/ | Save / share kiosks | βœ… wired | local (no server favorites API exists) | | Active / past sessions | βœ… wired | list views | | Start a paid session | 🟑 gated | flow wired to `postStartParkingSession`, disabled pending review (real charge) | -| Session-expiry reminders | βœ… wired | **local** notifications β€” no FCM needed | -| UnifiedPush (ntfy) | 🟑 partial | endpoint registration done; serverβ†’ntfy bridge still needed (see below) | +| Session-expiry reminders | βœ… wired | **local** on-device notifications β€” no server, no push | +| UnifiedPush (ntfy) | βšͺ optional | not needed for reminders; stub for future server-initiated msgs | ## Build & run (dev) @@ -61,12 +61,12 @@ in `app/app.json` to point elsewhere. ## Notifications on GrapheneOS -Session-expiry reminders are scheduled **locally** from each session's end time, so they -need no push infrastructure and work fully offline of Google. `app/src/notifications/` -also wires **UnifiedPush** (distributor: ntfy) for any genuinely server-initiated push β€” -but note the ParkSmarter backend only pushes via **FCM**, so server push requires a small -**FCMβ†’ntfy bridge** (a service holding an FCM token that forwards to your ntfy topic, then -registered via `PUT /api/Device`). Until that exists, local reminders cover the main case. +Session-expiry reminders are scheduled **entirely on-device** from each session's end time +(Android `AlarmManager`, via expo-notifications) β€” no server, no push, no FCM, no Play +Services. They work fully offline. Configure the lead time (default 15 min) in +**Account β†’ Notifications**, where a **"Send a test reminder"** button lets you confirm it +fires on your phone. UnifiedPush (ntfy) is wired only as an optional, no-op stub for any +*future* server-initiated messages; nothing time-based needs it. ## Distribution via Obtainium (self-hosted) diff --git a/app/src/notifications/localReminders.ts b/app/src/notifications/localReminders.ts index 4450ab6..da4b8ef 100644 --- a/app/src/notifications/localReminders.ts +++ b/app/src/notifications/localReminders.ts @@ -1,3 +1,4 @@ +import { Platform } from 'react-native'; import * as Notifications from 'expo-notifications'; import { getReminderLeadMinutes, @@ -5,15 +6,26 @@ import { } from '@/features/notifications/reminderPrefs'; /** - * Session-expiry reminders as LOCAL scheduled notifications. - * - * This is the key GrapheneOS win: because the app knows a session's end time, - * we schedule the reminder on-device β€” no server push, no FCM, no Play Services. - * UnifiedPush (see unifiedPush.ts) is reserved for genuinely server-initiated - * events that we can't predict locally. + * Session-expiry reminders are purely LOCAL scheduled notifications: the app knows + * the session's end time, so it schedules the reminder on-device via AlarmManager. + * No server, no FCM, no push, no Play Services β€” works fully offline on GrapheneOS. */ +const CHANNEL_ID = 'session-reminders'; + +/** Android 8+ needs a notification channel; ensure it exists once. */ +async function ensureChannel(): Promise { + if (Platform.OS !== 'android') return; + await Notifications.setNotificationChannelAsync(CHANNEL_ID, { + name: 'Parking reminders', + importance: Notifications.AndroidImportance.HIGH, + sound: 'default', + vibrationPattern: [0, 250, 250, 250], + }); +} + export async function ensureNotificationPermission(): Promise { + await ensureChannel(); const settings = await Notifications.getPermissionsAsync(); if (settings.granted) return true; const req = await Notifications.requestPermissionsAsync(); @@ -53,6 +65,7 @@ export async function scheduleExpiryReminder( const fireAt = new Date(args.endTime.getTime() - lead); if (fireAt.getTime() <= Date.now()) return null; // already too late + await ensureChannel(); return Notifications.scheduleNotificationAsync({ identifier: `session-${args.transactionId}`, content: { @@ -60,10 +73,31 @@ export async function scheduleExpiryReminder( body: `${args.zoneName} ends at ${args.endTime.toLocaleTimeString()}. Extend if you need more time.`, data: { transactionId: String(args.transactionId) }, }, - trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: fireAt }, + trigger: { + type: Notifications.SchedulableTriggerInputTypes.DATE, + date: fireAt, + channelId: CHANNEL_ID, + }, }); } export async function cancelExpiryReminder(transactionId: string | number): Promise { await Notifications.cancelScheduledNotificationAsync(`session-${transactionId}`); } + +/** Fire a test reminder a few seconds out β€” lets you confirm reminders work on-device. */ +export async function sendTestReminder(seconds = 10): Promise { + if (!(await ensureNotificationPermission())) return false; + await Notifications.scheduleNotificationAsync({ + content: { + title: 'Test reminder', + body: `This is what a parking-expiry reminder looks like (fired after ${seconds}s).`, + }, + trigger: { + type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, + seconds, + channelId: CHANNEL_ID, + }, + }); + return true; +} diff --git a/app/src/notifications/unifiedPush.ts b/app/src/notifications/unifiedPush.ts index 9786c6d..0974aa8 100644 --- a/app/src/notifications/unifiedPush.ts +++ b/app/src/notifications/unifiedPush.ts @@ -1,30 +1,16 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -import { ps } from '@/api/client'; /** - * UnifiedPush integration (distributor: ntfy). + * OPTIONAL UnifiedPush (ntfy) registration. * - * IMPORTANT ARCHITECTURE NOTE - * --------------------------- - * The ParkSmarter backend only delivers push via FCM, keyed on the device token - * registered through `PUT /api/Device`. A UnifiedPush endpoint (an ntfy URL) - * cannot receive FCM directly, so server-initiated push requires a BRIDGE: + * Nothing time-based needs this: parking session-expiry reminders are scheduled + * on-device (see localReminders.ts) with no server or push involved. This module + * exists only for *future* genuinely server-initiated messages, delivered through + * the user's own UnifiedPush distributor (e.g. ntfy) β€” self-hosted, no FCM, no + * Google. It's a no-op unless a distributor is installed. * - * ParkSmarter server --FCM--> [bridge holding an FCM token] --HTTP--> ntfy topic - * | - * UnifiedPush distributor (ntfy app) - * v - * this app - * - * Until that bridge exists, session-expiry reminders are handled entirely by - * on-device local notifications (see localReminders.ts), which covers the main - * use case without any server push. This module wires the UnifiedPush side so - * the app is ready to be a push target once the bridge (or a self-hosted relay - * that we register with `PUT /api/Device`) is in place. - * - * The `react-native-unifiedpush-connector` API surface varies by version; treat the - * calls below as the integration point to confirm against the installed version. - * It's lazy-required so the app runs fine even before push is fully wired. + * The `react-native-unifiedpush-connector` API varies by version; lazy-required so + * the app runs fine without it. */ const ENDPOINT_KEY = 'ps_unifiedpush_endpoint'; @@ -39,31 +25,20 @@ function loadUnifiedPush(): any | null { } } -/** Kick off distributor discovery + registration (call after login). */ +/** Register with the user's UnifiedPush distributor (ntfy), if one is installed. */ export async function registerUnifiedPush(): Promise { const UnifiedPush = loadUnifiedPush(); if (!UnifiedPush) return; const distributors: string[] = await UnifiedPush.getDistributors(); - if (!distributors.length) { - // No UnifiedPush distributor installed (e.g. ntfy). Local reminders still work. - return; - } + if (!distributors.length) return; // no ntfy/distributor -> nothing to do const saved = await UnifiedPush.getSavedDistributor?.(); - const distributor = saved ?? distributors[0]; - await UnifiedPush.saveDistributor(distributor); + await UnifiedPush.saveDistributor(saved ?? distributors[0]); await UnifiedPush.registerDevice(INSTANCE); } -/** - * Handle the endpoint the distributor hands back (wire this to the library's - * `onNewEndpoint` event in the app root). We persist it and, once a bridge is - * available, register it with the backend so the server can reach us. - */ +/** Persist the ntfy endpoint the distributor hands back. */ export async function onNewEndpoint(endpoint: string): Promise { await AsyncStorage.setItem(ENDPOINT_KEY, endpoint); - // When the FCM->ntfy bridge is live, register the bridge-issued token here: - // await ps.updateDeviceToken({ pushNotificationsToken: bridgeToken, deviceType: '1' }); - void ps; // referenced so the intended integration is explicit } export async function getSavedEndpoint(): Promise { diff --git a/app/src/screens/NotificationsScreen.tsx b/app/src/screens/NotificationsScreen.tsx index 3333497..15557b4 100644 --- a/app/src/screens/NotificationsScreen.tsx +++ b/app/src/screens/NotificationsScreen.tsx @@ -1,7 +1,8 @@ import React, { useCallback, useState } from 'react'; -import { StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; +import { Alert, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; import { useFocusEffect } from '@react-navigation/native'; import { useTheme } from '@/theme/ThemeContext'; +import { sendTestReminder } from '@/notifications/localReminders'; import { DEFAULT_LEAD_MINUTES, LEAD_STEP, @@ -80,9 +81,24 @@ export function NotificationsScreen() { + { + const ok = await sendTestReminder(10); + Alert.alert( + ok ? 'Test scheduled' : 'Notifications blocked', + ok + ? 'A test reminder will appear in ~10 seconds (you can background the app).' + : 'Allow notifications for BigBrainParking in system settings.', + ); + }} + > + Send a test reminder + + - Reminders are scheduled on-device (no Google services needed). Adjusts in{' '} - {LEAD_STEP}-minute steps, {MIN_LEAD_MINUTES}–{MAX_LEAD_MINUTES} min. + Reminders fire entirely on-device (AlarmManager) β€” no server, no push, no Google + services. Adjusts in {LEAD_STEP}-minute steps, {MIN_LEAD_MINUTES}–{MAX_LEAD_MINUTES} min. ); @@ -108,5 +124,12 @@ const styles = StyleSheet.create({ leadDisplay: { alignItems: 'center' }, leadValue: { fontSize: 40, fontWeight: '800' }, leadUnit: { fontSize: 13, marginTop: 2 }, - footnote: { fontSize: 12, marginTop: 20, lineHeight: 18 }, + testBtn: { + marginTop: 20, + borderWidth: 1.5, + borderRadius: 12, + padding: 14, + alignItems: 'center', + }, + footnote: { fontSize: 12, marginTop: 16, lineHeight: 18 }, });