UNPKG

expo-notifications

Version:

Provides an API to fetch push notification tokens and to present, schedule, receive, and respond to notifications.

234 lines (206 loc) 7 kB
import * as Application from 'expo-application'; import { CodedError, Platform, UnavailabilityError } from 'expo-modules-core'; import { computeNextBackoffInterval } from './backoff'; import ServerRegistrationModule from '../ServerRegistrationModule'; import { DevicePushToken } from '../Tokens.types'; const updateDevicePushTokenUrl = 'https://exp.host/--/api/v2/push/updateDeviceToken'; const LAST_TOKEN_KEY = 'lastRegisteredDeviceToken'; type StoredTokenData = { deviceToken: string; appId: string | null; development: boolean; type: string; registeredAt: number; }; // Force re-registration after 7 days even if nothing changed, in case the // server lost the device record (cleanup, migration, etc.). const REGISTRATION_TTL_MS = 7 * 24 * 60 * 60 * 1000; async function getLastRegisteredTokenDataAsync(): Promise<StoredTokenData | null> { try { if (!ServerRegistrationModule.getRegistrationInfoAsync) { return null; } const info = await ServerRegistrationModule.getRegistrationInfoAsync(); if (!info) { return null; } const parsed = JSON.parse(info); return parsed?.[LAST_TOKEN_KEY] ?? null; } catch { return null; } } async function setLastRegisteredTokenDataAsync(tokenData: StoredTokenData): Promise<void> { try { if ( !ServerRegistrationModule.getRegistrationInfoAsync || !ServerRegistrationModule.setRegistrationInfoAsync ) { return; } const info = await ServerRegistrationModule.getRegistrationInfoAsync(); const existing = info ? JSON.parse(info) : {}; existing[LAST_TOKEN_KEY] = tokenData; await ServerRegistrationModule.setRegistrationInfoAsync(JSON.stringify(existing)); } catch { // Best-effort — next app open will re-register } } /** * Returns `true` if the device token or metadata has changed since the last * successful registration, or if the check cannot be performed (fail-open). */ export async function hasDeviceTokenChangedAsync(token: DevicePushToken): Promise<boolean> { try { const development = await shouldUseDevelopmentNotificationService(); const lastTokenData = await getLastRegisteredTokenDataAsync(); if (lastTokenData == null) { return true; } const age = Date.now() - (lastTokenData.registeredAt ?? 0); if (age < 0 || age >= REGISTRATION_TTL_MS) { return true; } return ( token.data !== lastTokenData.deviceToken || Application.applicationId !== lastTokenData.appId || development !== lastTokenData.development || getTypeOfToken(token) !== lastTokenData.type ); } catch { return true; } } export async function updateDevicePushTokenAsync(signal: AbortSignal, token: DevicePushToken) { const doUpdateDevicePushTokenAsync = async (retry: () => void) => { const [development, deviceId] = await Promise.all([ shouldUseDevelopmentNotificationService(), getDeviceIdAsync(), ]); const body = { deviceId: deviceId.toLowerCase(), development, deviceToken: token.data, appId: Application.applicationId, type: getTypeOfToken(token), }; try { const response = await fetch(updateDevicePushTokenUrl, { method: 'POST', headers: { 'content-type': 'application/json', }, body: JSON.stringify(body), signal, }); // Help debug erroring servers if (!response.ok) { console.debug( '[expo-notifications] Error encountered while updating the device push token with the server:', await response.text() ); } if (response.ok) { await setLastRegisteredTokenDataAsync({ deviceToken: token.data, appId: Application.applicationId, development, type: getTypeOfToken(token), registeredAt: Date.now(), }); } // Retry if request failed if (!response.ok) { retry(); } } catch (error: any) { // Error returned if the request is aborted should be an 'AbortError'. In // React Native fetch is polyfilled using `whatwg-fetch` which: // - creates `AbortError`s like this // https://github.com/github/fetch/blob/75d9455d380f365701151f3ac85c5bda4bbbde76/fetch.js#L505 // - which creates exceptions like // https://github.com/github/fetch/blob/75d9455d380f365701151f3ac85c5bda4bbbde76/fetch.js#L490-L494 if (typeof error === 'object' && error?.name === 'AbortError') { // We don't consider AbortError a failure, it's a sign somewhere else the // request is expected to succeed and we don't need this one, so let's // just return. return; } console.warn( '[expo-notifications] Error thrown while updating the device push token with the server:', error ); retry(); } }; let shouldTry = true; const retry = () => { shouldTry = true; }; let retriesCount = 0; const initialBackoff = 500; // 0.5 s const backoffOptions = { maxBackoff: 2 * 60 * 1000, // 2 minutes }; let nextBackoffInterval = computeNextBackoffInterval( initialBackoff, retriesCount, backoffOptions ); while (shouldTry && !signal.aborted) { // Will be set to true by `retry` if it's called shouldTry = false; await doUpdateDevicePushTokenAsync(retry); // Do not wait if we won't retry if (shouldTry && !signal.aborted) { nextBackoffInterval = computeNextBackoffInterval( initialBackoff, retriesCount, backoffOptions ); retriesCount += 1; await new Promise((resolve) => setTimeout(resolve, nextBackoffInterval)); } } } // Same as in getExpoPushTokenAsync async function getDeviceIdAsync() { try { if (!ServerRegistrationModule.getInstallationIdAsync) { throw new UnavailabilityError('ExpoServerRegistrationModule', 'getInstallationIdAsync'); } return await ServerRegistrationModule.getInstallationIdAsync(); } catch (e) { throw new CodedError( 'ERR_NOTIFICATIONS_DEVICE_ID', `Could not fetch the installation ID of the application: ${e}.` ); } } // Same as in getExpoPushTokenAsync function getTypeOfToken(devicePushToken: DevicePushToken) { switch (devicePushToken.type) { case 'ios': return 'apns'; case 'android': return 'fcm'; // This probably will error on server, but let's make this function future-safe. default: return devicePushToken.type; } } // Same as in getExpoPushTokenAsync async function shouldUseDevelopmentNotificationService() { if (Platform.OS === 'ios') { try { const notificationServiceEnvironment = await Application.getIosPushNotificationServiceEnvironmentAsync(); if (notificationServiceEnvironment === 'development') { return true; } } catch { // We can't do anything here, we'll fallback to false then. } } return false; }