Notifications: local-only session reminders; drop the FCM-bridge framing

- Session-expiry reminders are purely on-device (AlarmManager); added an Android
  notification channel and a "Send a test reminder" button (Account > Notifications)
  to verify delivery without a real session
- Strip all FCM/bridge talk from unifiedPush.ts + README; UnifiedPush(ntfy) is now
  an optional no-op stub for hypothetical future server-initiated messages only

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-07 14:14:34 -07:00
parent 0bc9aa97ba
commit 02e2c750ea
4 changed files with 88 additions and 56 deletions

View file

@ -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)

View file

@ -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<void> {
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<boolean> {
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<void> {
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<boolean> {
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;
}

View file

@ -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<void> {
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<void> {
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<string | null> {

View file

@ -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() {
</View>
</View>
<TouchableOpacity
style={[styles.testBtn, { borderColor: colors.primary }]}
onPress={async () => {
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.',
);
}}
>
<Text style={{ color: colors.primary, fontWeight: '700' }}>Send a test reminder</Text>
</TouchableOpacity>
<Text style={[styles.footnote, { color: colors.subtext }]}>
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.
</Text>
</View>
);
@ -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 },
});