fix: always upsert push token on mobile start

This commit is contained in:
talksik
2026-05-18 11:59:31 -07:00
parent 90d9acff4d
commit 90ff18a788
+15 -49
View File
@@ -1,14 +1,11 @@
import Constants from "expo-constants"; import Constants from "expo-constants";
import * as Device from "expo-device"; import * as Device from "expo-device";
import * as Notifications from "expo-notifications"; import * as Notifications from "expo-notifications";
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { apiClient } from "@/api/client"; import { apiClient } from "@/api/client";
import { logError } from "@/lib/errors"; import { logError } from "@/lib/errors";
import { routeNotificationTap } from "@/lib/notification-routing"; import { routeNotificationTap } from "@/lib/notification-routing";
const STORED_TOKEN_KEY = "expo_push_token";
let configured = false; let configured = false;
let tokenListenerSubscription: Notifications.Subscription | null = null; let tokenListenerSubscription: Notifications.Subscription | null = null;
@@ -49,9 +46,9 @@ export function configureNotifications(): void {
} }
/** /**
* Acquires (or returns the cached) Expo push token for this device. Returns * Acquires the Expo push token for this device. Returns null on simulators,
* null on simulators, when permission is denied, or when any step fails — the * when permission is denied, or when any step fails — the caller should treat
* caller should treat that as "no push, no further action". * that as "no push, no further action".
*/ */
async function acquirePushToken(): Promise<string | null> { async function acquirePushToken(): Promise<string | null> {
if (!Device.isDevice) return null; if (!Device.isDevice) return null;
@@ -78,43 +75,18 @@ async function acquirePushToken(): Promise<string | null> {
return tokenResult.data; return tokenResult.data;
} }
async function getStoredToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(STORED_TOKEN_KEY);
} catch {
return null;
}
}
async function setStoredToken(token: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORED_TOKEN_KEY, token);
} catch (err) {
logError(err, { scope: "push.store" });
}
}
async function clearStoredToken(): Promise<void> {
try {
await SecureStore.deleteItemAsync(STORED_TOKEN_KEY);
} catch {
// ignore
}
}
/** /**
* Compares the freshly-fetched token to whatever we last sent to Orion and * Posts the current Expo token to Orion. Runs on every launch and on token
* only POSTs on a delta. Never throws — push registration is best-effort and * rotation — the backend upsert is idempotent so re-posting is cheap, and
* must never block the auth path. * skipping the local dedupe means an env switch (e.g. dev → prod build on the
* same device) re-registers correctly. Never throws — push registration is
* best-effort and must never block the auth path.
*/ */
export async function syncPushToken(token?: string | null): Promise<void> { export async function syncPushToken(token?: string | null): Promise<void> {
try { try {
const next = token ?? (await acquirePushToken()); const next = token ?? (await acquirePushToken());
if (!next) return; if (!next) return;
const stored = await getStoredToken();
if (stored === next) return;
const platform = Platform.OS === "ios" ? "ios" : "android"; const platform = Platform.OS === "ios" ? "ios" : "android";
const appVersion = Constants.expoConfig?.version ?? ""; const appVersion = Constants.expoConfig?.version ?? "";
@@ -123,28 +95,22 @@ export async function syncPushToken(token?: string | null): Promise<void> {
platform, platform,
app_version: appVersion, app_version: appVersion,
}); });
await setStoredToken(next);
} catch (err) { } catch (err) {
logError(err, { scope: "push.sync" }); logError(err, { scope: "push.sync" });
} }
} }
/** /**
* Best-effort unregister at sign-out. Wipes the stored token even if the * Best-effort unregister at sign-out. Fetches the current token (without
* server call fails so the next signed-in user re-registers cleanly. * prompting — getPermissionsAsync is read-only) and deletes it server-side.
*/ */
export async function unregisterPushToken(): Promise<void> { export async function unregisterPushToken(): Promise<void> {
try { try {
const stored = await getStoredToken(); const token = await acquirePushToken();
if (stored) { if (!token) return;
try { await apiClient.unregisterPushToken(token);
await apiClient.unregisterPushToken(stored); } catch (err) {
} catch (err) { logError(err, { scope: "push.unregister" });
logError(err, { scope: "push.unregister" });
}
}
} finally {
await clearStoredToken();
} }
} }