From 212db8a5843fb04370014d19d64a2895e3ca2716 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Sun, 12 Jul 2026 19:36:13 +0800 Subject: [PATCH] fix(mobile): restore push notification registration --- apps/mobile/src/hooks/useMessaging.ts | 46 ++++- apps/mobile/src/lib/auth.ts | 1 + .../src/lib/messaging-registration.test.ts | 163 ++++++++++++++++++ apps/mobile/src/lib/messaging-registration.ts | 113 ++++++++++++ 4 files changed, 317 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/lib/messaging-registration.test.ts create mode 100644 apps/mobile/src/lib/messaging-registration.ts diff --git a/apps/mobile/src/hooks/useMessaging.ts b/apps/mobile/src/hooks/useMessaging.ts index 3b7ca6776..e502a60ce 100644 --- a/apps/mobile/src/hooks/useMessaging.ts +++ b/apps/mobile/src/hooks/useMessaging.ts @@ -1,6 +1,7 @@ import { useHasNotificationActions } from "@follow/store/action/hooks" import { ROUTE_FEED_IN_INBOX } from "@follow/store/constants/app" import { useWhoami } from "@follow/store/user/hooks" +import { tracker } from "@follow/tracker" import { getApp } from "@react-native-firebase/app" import type { FirebaseMessagingTypes } from "@react-native-firebase/messaging" import { getMessaging } from "@react-native-firebase/messaging" @@ -10,15 +11,17 @@ import { Platform } from "react-native" import { followClient } from "@/src/lib/api-client" import { kv } from "@/src/lib/kv" +import { + registerMessagingToken, + shouldRetryMessagingTokenRegistration, +} from "@/src/lib/messaging-registration" import { useNavigation } from "@/src/lib/navigation/hooks" import { requestNotificationPermission } from "@/src/lib/permission" import { EntryDetailScreen } from "@/src/screens/(stack)/entries/[entryId]/EntryDetailScreen" const FIREBASE_MESSAGING_TOKEN_STORAGE_KEY = "firebase_messaging_token" -async function saveMessagingToken() { - const app = getApp() - const token = await getMessaging(app).getToken() +async function saveMessagingToken(token: string) { await followClient.api.messaging.createToken({ token, channel: Platform.OS }) kv.set(FIREBASE_MESSAGING_TOKEN_STORAGE_KEY, token) } @@ -27,14 +30,45 @@ export function useUpdateMessagingToken() { const whoami = useWhoami() const hasNotificationActions = useHasNotificationActions() const { mutate } = useMutation({ - mutationFn: async () => { - return Promise.all([saveMessagingToken(), requestNotificationPermission()]) + scope: { id: "mobile-push-token-registration" }, + mutationFn: async ({ token }: { token?: string }) => { + if (token) { + await saveMessagingToken(token) + return token + } + + const messaging = getMessaging(getApp()) + return registerMessagingToken({ + platform: Platform.OS, + requestPermission: requestNotificationPermission, + registerDeviceForRemoteMessages: () => messaging.registerDeviceForRemoteMessages(), + getAPNSToken: () => messaging.getAPNSToken(), + getToken: () => messaging.getToken(), + saveToken: saveMessagingToken, + }) + }, + retry: shouldRetryMessagingTokenRegistration, + retryDelay: (attemptIndex) => Math.min(500 * 2 ** attemptIndex, 4_000), + onError: (error) => { + console.error("Failed to register push notifications", error) + void tracker.manager.captureException(error, { + module: "push_notifications", + stage: "token_registration", + platform: Platform.OS, + }) }, }) useEffect(() => { if (!whoami?.id || !hasNotificationActions) return - mutate() + + const messaging = getMessaging(getApp()) + const unsubscribe = messaging.onTokenRefresh((token) => { + mutate({ token }) + }) + + mutate({}) + return unsubscribe }, [hasNotificationActions, mutate, whoami?.id]) } diff --git a/apps/mobile/src/lib/auth.ts b/apps/mobile/src/lib/auth.ts index 17736eea2..43c758d5a 100644 --- a/apps/mobile/src/lib/auth.ts +++ b/apps/mobile/src/lib/auth.ts @@ -52,6 +52,7 @@ const refreshSessionQueries = () => queryClient.resetQueries({ queryKey: ["subscription"] }), queryClient.resetQueries({ queryKey: ["unread"] }), queryClient.resetQueries({ queryKey: ["owned", "lists"] }), + queryClient.resetQueries({ queryKey: ["action", "rules"] }), ]) type MobileAuthPlugins = [ diff --git a/apps/mobile/src/lib/messaging-registration.test.ts b/apps/mobile/src/lib/messaging-registration.test.ts new file mode 100644 index 000000000..db002e440 --- /dev/null +++ b/apps/mobile/src/lib/messaging-registration.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from "vitest" + +import { + APNSTokenUnavailableError, + registerMessagingToken, + shouldRetryMessagingTokenRegistration, +} from "./messaging-registration" + +describe("messaging registration", () => { + it("waits for an APNs token before requesting and saving the FCM token", async () => { + const calls: string[] = [] + const getAPNSToken = vi + .fn<() => Promise>() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("apns-token") + + const result = await registerMessagingToken({ + platform: "ios", + requestPermission: async () => { + calls.push("permission") + return true + }, + registerDeviceForRemoteMessages: async () => { + calls.push("register") + }, + getAPNSToken: async () => { + calls.push("apns") + return getAPNSToken() + }, + getToken: async () => { + calls.push("fcm") + return "fcm-token" + }, + saveToken: async (token) => { + calls.push(`save:${token}`) + }, + apnsTokenRetryDelaysMs: [10], + waitForRetry: async (delayMs) => { + calls.push(`wait:${delayMs}`) + }, + }) + + expect(result).toBe("fcm-token") + expect(calls).toEqual([ + "permission", + "apns", + "register", + "apns", + "wait:10", + "apns", + "fcm", + "save:fcm-token", + ]) + }) + + it("uses an existing APNs token without registering again", async () => { + const registerDeviceForRemoteMessages = vi.fn<() => Promise>() + + await registerMessagingToken({ + platform: "ios", + requestPermission: async () => true, + registerDeviceForRemoteMessages, + getAPNSToken: async () => "apns-token", + getToken: async () => "fcm-token", + saveToken: async () => {}, + }) + + expect(registerDeviceForRemoteMessages).not.toHaveBeenCalled() + }) + + it("re-attempts native APNs registration after a registration failure", async () => { + let apnsToken: string | null = null + const registerDeviceForRemoteMessages = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("registration failed")) + .mockImplementationOnce(async () => { + apnsToken = "apns-token" + }) + + const registration = () => + registerMessagingToken({ + platform: "ios", + requestPermission: async () => true, + registerDeviceForRemoteMessages, + getAPNSToken: async () => apnsToken, + getToken: async () => "fcm-token", + saveToken: async () => {}, + apnsTokenRetryDelaysMs: [], + }) + + await expect(registration()).rejects.toThrow("registration failed") + await expect(registration()).resolves.toBe("fcm-token") + expect(registerDeviceForRemoteMessages).toHaveBeenCalledTimes(2) + }) + + it("does not request a token when notification permission is denied", async () => { + const getToken = vi.fn<() => Promise>() + const saveToken = vi.fn<(token: string) => Promise>() + + const result = await registerMessagingToken({ + platform: "ios", + requestPermission: async () => false, + registerDeviceForRemoteMessages: vi.fn<() => Promise>(), + getAPNSToken: vi.fn<() => Promise>(), + getToken, + saveToken, + }) + + expect(result).toBeNull() + expect(getToken).not.toHaveBeenCalled() + expect(saveToken).not.toHaveBeenCalled() + }) + + it("skips APNs registration on Android", async () => { + const registerDeviceForRemoteMessages = vi.fn<() => Promise>() + const getAPNSToken = vi.fn<() => Promise>() + const saveToken = vi.fn<(token: string) => Promise>().mockResolvedValue() + + const result = await registerMessagingToken({ + platform: "android", + requestPermission: async () => true, + registerDeviceForRemoteMessages, + getAPNSToken, + getToken: async () => "android-fcm-token", + saveToken, + }) + + expect(result).toBe("android-fcm-token") + expect(registerDeviceForRemoteMessages).not.toHaveBeenCalled() + expect(getAPNSToken).not.toHaveBeenCalled() + expect(saveToken).toHaveBeenCalledWith("android-fcm-token") + }) + + it("fails before requesting an FCM token when the APNs token never arrives", async () => { + const getToken = vi.fn<() => Promise>() + const saveToken = vi.fn<(token: string) => Promise>() + + await expect( + registerMessagingToken({ + platform: "ios", + requestPermission: async () => true, + registerDeviceForRemoteMessages: vi.fn<() => Promise>(), + getAPNSToken: async () => null, + getToken, + saveToken, + apnsTokenRetryDelaysMs: [10, 20], + waitForRetry: async () => {}, + }), + ).rejects.toBeInstanceOf(APNSTokenUnavailableError) + + expect(getToken).not.toHaveBeenCalled() + expect(saveToken).not.toHaveBeenCalled() + }) + + it("retries only transient failures and stops after three retries", () => { + expect(shouldRetryMessagingTokenRegistration(0, new Error("offline"))).toBe(true) + expect(shouldRetryMessagingTokenRegistration(2, { status: 503 })).toBe(true) + expect(shouldRetryMessagingTokenRegistration(2, { statusCode: 429 })).toBe(true) + expect(shouldRetryMessagingTokenRegistration(2, { status: 401 })).toBe(false) + expect(shouldRetryMessagingTokenRegistration(3, new Error("offline"))).toBe(false) + }) +}) diff --git a/apps/mobile/src/lib/messaging-registration.ts b/apps/mobile/src/lib/messaging-registration.ts new file mode 100644 index 000000000..292cca769 --- /dev/null +++ b/apps/mobile/src/lib/messaging-registration.ts @@ -0,0 +1,113 @@ +const DEFAULT_APNS_TOKEN_RETRY_DELAYS_MS = [250, 500, 1_000, 2_000] as const +const TRANSIENT_HTTP_STATUS_CODES = new Set([408, 425, 429]) +const MAX_RETRY_COUNT = 3 + +const wait = (delayMs: number) => + new Promise((resolve) => { + setTimeout(resolve, delayMs) + }) + +const getErrorStatus = (error: unknown) => { + if (!error || typeof error !== "object") { + return + } + + let status: unknown + if ("status" in error) { + status = error.status + } else if ("statusCode" in error) { + status = error.statusCode + } else { + return + } + + const numericStatus = Number(status) + if (Number.isInteger(numericStatus)) { + return numericStatus + } +} + +export class APNSTokenUnavailableError extends Error { + constructor() { + super("APNs token is not available after registering for remote messages") + this.name = "APNSTokenUnavailableError" + } +} + +export function shouldRetryMessagingTokenRegistration(failureCount: number, error: unknown) { + if (failureCount >= MAX_RETRY_COUNT) { + return false + } + + const status = getErrorStatus(error) + if (status === undefined) { + return true + } + + return status >= 500 || TRANSIENT_HTTP_STATUS_CODES.has(status) +} + +export async function waitForAPNSToken({ + getAPNSToken, + retryDelaysMs = DEFAULT_APNS_TOKEN_RETRY_DELAYS_MS, + waitForRetry = wait, +}: { + getAPNSToken: () => Promise + retryDelaysMs?: readonly number[] + waitForRetry?: (delayMs: number) => Promise +}) { + for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) { + const token = await getAPNSToken() + if (token) { + return token + } + + const retryDelayMs = retryDelaysMs[attempt] + if (retryDelayMs !== undefined) { + await waitForRetry(retryDelayMs) + } + } + + throw new APNSTokenUnavailableError() +} + +export async function registerMessagingToken({ + platform, + requestPermission, + registerDeviceForRemoteMessages, + getAPNSToken, + getToken, + saveToken, + apnsTokenRetryDelaysMs, + waitForRetry, +}: { + platform: string + requestPermission: () => Promise + registerDeviceForRemoteMessages: () => Promise + getAPNSToken: () => Promise + getToken: () => Promise + saveToken: (token: string) => Promise + apnsTokenRetryDelaysMs?: readonly number[] + waitForRetry?: (delayMs: number) => Promise +}) { + const permissionGranted = await requestPermission() + if (!permissionGranted) { + return null + } + + if (platform === "ios") { + const existingAPNSToken = await getAPNSToken() + if (!existingAPNSToken) { + await registerDeviceForRemoteMessages() + await waitForAPNSToken({ + getAPNSToken, + retryDelaysMs: apnsTokenRetryDelaysMs, + waitForRetry, + }) + } + } + + const token = await getToken() + await saveToken(token) + return token +}