fix(mobile): restore push notification registration

This commit is contained in:
DIYgod 2026-07-12 19:36:13 +08:00
parent 451e263db4
commit 212db8a584
4 changed files with 317 additions and 6 deletions

View File

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

View File

@ -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 = [

View File

@ -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<string | null>>()
.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<void>>()
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<void>>()
.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<string>>()
const saveToken = vi.fn<(token: string) => Promise<void>>()
const result = await registerMessagingToken({
platform: "ios",
requestPermission: async () => false,
registerDeviceForRemoteMessages: vi.fn<() => Promise<void>>(),
getAPNSToken: vi.fn<() => Promise<string | null>>(),
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<void>>()
const getAPNSToken = vi.fn<() => Promise<string | null>>()
const saveToken = vi.fn<(token: string) => Promise<void>>().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<string>>()
const saveToken = vi.fn<(token: string) => Promise<void>>()
await expect(
registerMessagingToken({
platform: "ios",
requestPermission: async () => true,
registerDeviceForRemoteMessages: vi.fn<() => Promise<void>>(),
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)
})
})

View File

@ -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<void>((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<string | null>
retryDelaysMs?: readonly number[]
waitForRetry?: (delayMs: number) => Promise<void>
}) {
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<boolean>
registerDeviceForRemoteMessages: () => Promise<void>
getAPNSToken: () => Promise<string | null>
getToken: () => Promise<string>
saveToken: (token: string) => Promise<void>
apnsTokenRetryDelaysMs?: readonly number[]
waitForRetry?: (delayMs: number) => Promise<void>
}) {
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
}