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:
parent
0bc9aa97ba
commit
02e2c750ea
4 changed files with 88 additions and 56 deletions
16
README.md
16
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue