Files
TREK/client/src/native/push.ts
T
Maurice 3c4c880c31 feat(native): push notifications on Android through the relay
The Android app now subscribes like the iOS one: TrekShell.registerPush asks
for the notification permission (Android 13+), creates the device's P-256
Web Push keys and returns the relay endpoint for its FCM token
(push.liketrek.com/v1/fcm/<token>). TrekPushService receives the relay's
data message, decrypts the record (RFC 8291, TrekWebPush) and shows the
notification. A tap opens the linked page, also when it starts the app.

The keys are sealed with an AES key kept in the Android Keystore. Firebase
is configured from res/values/firebase.xml, so no google-services plugin is
needed. The decryption is tested against the RFC 8291 example, and the
native workflow now runs the Android unit tests. On the web side push is no
longer limited to iOS: it follows the nativePush capability on both platforms.
2026-11-06 15:02:55 +01:00

86 lines
3.0 KiB
TypeScript

import type { PushSubscriptionInput } from '@trek/shared';
import { hasNativeFeature } from './capabilities';
import { isNativeApp } from './platform';
import { reportNativeFailure } from './reportFailure';
import { TrekShell } from './trekShell';
/**
* Push inside the app: the same Web Push subscription a browser makes, only
* the app makes it (TrekShell.registerPush). Its endpoint is liketrek's relay
* for this device's APNs or FCM token and its keys live on the device, so the
* server encrypts exactly as for a browser and the relay hands on ciphertext
* that the app decrypts (iOS: the notification extension, Android:
* TrekPushService). push/webPush.ts takes this path
* wherever the binary reports the nativePush feature; the settings card, the
* test push and the logout teardown stay what they are.
*
* The endpoint is remembered on the device, the way a browser remembers its
* subscription, so the card can say on or off and the logout can name it.
*/
const KEY = 'trek.nativePushEndpoint';
export function nativePushActive(): boolean {
return isNativeApp() && hasNativeFeature('nativePush');
}
export function nativePushEndpoint(): string | null {
try {
return window.localStorage.getItem(KEY);
} catch {
return null;
}
}
function rememberEndpoint(endpoint: string | null): void {
try {
if (endpoint) window.localStorage.setItem(KEY, endpoint);
else window.localStorage.removeItem(KEY);
} catch {
// Storage refused: the card then reads off and offers to switch it on again.
}
}
export class NativePushDenied extends Error {}
/** Permission, APNs and the device keys, as a subscription the server can store. */
export async function registerNativePush(): Promise<PushSubscriptionInput> {
try {
const subscription = await TrekShell.registerPush();
rememberEndpoint(subscription.endpoint);
return subscription;
} catch (err) {
if ((err as { code?: string } | null)?.code === 'DENIED') throw new NativePushDenied();
throw err;
}
}
export function forgetNativePush(): void {
rememberEndpoint(null);
}
/** Opens the place a tapped notification points at, inside the app's own routing. */
export function openPushTarget(url: string): void {
let target: URL;
try {
target = new URL(url, window.location.origin);
} catch {
return;
}
// Only this server's pages: a notification never navigates the app elsewhere.
if (target.origin !== window.location.origin) return;
window.history.pushState({}, '', `${target.pathname}${target.search}${target.hash}`);
window.dispatchEvent(new PopStateEvent('popstate'));
}
/** Follows taps on notifications, including the one the app was opened from. */
export async function startNativePush(): Promise<void> {
if (!nativePushActive()) return;
void TrekShell.addListener('pushOpened', ({ url }) => openPushTarget(url));
try {
const { url } = await TrekShell.takeOpenedPush();
if (url) openPushTarget(url);
} catch (err) {
reportNativeFailure(err);
}
}