fix(mobile): restore push notification registration
This commit is contained in:
parent
451e263db4
commit
212db8a584
|
|
@ -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])
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue