feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

View File

@@ -0,0 +1,7 @@
import { requireNativeModule } from 'expo-modules-core';
import { BackgroundNotificationTasksModule } from './BackgroundNotificationTasksModule.types';
export default requireNativeModule<BackgroundNotificationTasksModule>(
'ExpoBackgroundNotificationTasksModule'
);

View File

@@ -0,0 +1,8 @@
export default {
async registerTaskAsync(taskName: string): Promise<null> {
return null;
},
async unregisterTaskAsync(taskName: string): Promise<null> {
return null;
},
};

View File

@@ -0,0 +1,12 @@
import { ProxyNativeModule } from 'expo-modules-core';
export interface BackgroundNotificationTasksModule extends ProxyNativeModule {
registerTaskAsync: (taskName: string) => Promise<null>;
unregisterTaskAsync: (taskName: string) => Promise<null>;
}
export enum BackgroundNotificationResult {
NoData = 1,
NewData = 2,
Failed = 3,
}

View File

@@ -0,0 +1,15 @@
import { requireNativeModule } from 'expo-modules-core';
import { BadgeModule } from './BadgeModule.types';
const nativeModule = requireNativeModule('ExpoBadgeModule');
export default {
...nativeModule,
// We overwrite setBadgeCountAsync to omit
// an obsolete options argument when calling
// the native function.
setBadgeCountAsync: async (badgeCount, options) => {
return await nativeModule.setBadgeCountAsync(badgeCount);
},
} as BadgeModule;

View File

@@ -0,0 +1,6 @@
import { BadgeModule } from './BadgeModule.types';
export default {
addListener: () => {},
removeListeners: () => {},
} as BadgeModule;

View File

@@ -0,0 +1,10 @@
import { Options as BadginOptions } from 'badgin';
import { ProxyNativeModule } from 'expo-modules-core';
export type WebSetBadgeCountOptions = BadginOptions;
type SetBadgeCountOptions = WebSetBadgeCountOptions | undefined;
export interface BadgeModule extends ProxyNativeModule {
getBadgeCountAsync?: () => Promise<number>;
setBadgeCountAsync?: (badgeCount: number, options: SetBadgeCountOptions) => Promise<boolean>;
}

View File

@@ -0,0 +1,28 @@
import { BadgeModule } from './BadgeModule.types';
let lastSetBadgeCount = 0;
const badgeModule: BadgeModule = {
addListener: () => {},
removeListeners: () => {},
getBadgeCountAsync: async () => {
return lastSetBadgeCount;
},
setBadgeCountAsync: async (badgeCount, options) => {
// If this module is loaded in SSR (NextJS), we can't modify the badge.
// It also can't load the badgin module, that instantly invokes methods on window.
if (typeof window === 'undefined') {
return false;
}
const badgin = require('badgin');
if (badgeCount > 0) {
badgin.set(badgeCount, options);
} else {
badgin.clear();
}
lastSetBadgeCount = badgeCount;
return true;
},
};
export default badgeModule;

View File

@@ -0,0 +1,119 @@
import 'abort-controller/polyfill';
import { UnavailabilityError } from 'expo-modules-core';
import ServerRegistrationModule from './ServerRegistrationModule';
import { addPushTokenListener } from './TokenEmitter';
import { DevicePushToken } from './Tokens.types';
import getDevicePushTokenAsync from './getDevicePushTokenAsync';
import { updateDevicePushTokenAsync as updateDevicePushTokenAsyncWithSignal } from './utils/updateDevicePushTokenAsync';
let lastAbortController: AbortController | null = null;
async function updatePushTokenAsync(token: DevicePushToken) {
// Abort current update process
lastAbortController?.abort();
lastAbortController = new AbortController();
return await updateDevicePushTokenAsyncWithSignal(lastAbortController.signal, token);
}
/**
* Encapsulates device server registration data
*/
export type DevicePushTokenRegistration = {
isEnabled: boolean;
};
/**
* Sets the registration information so that the device push token gets pushed
* to the given registration endpoint
* @param enabled
*/
export async function setAutoServerRegistrationEnabledAsync(enabled: boolean) {
// We are overwriting registration, so we shouldn't let
// any pending request complete.
lastAbortController?.abort();
if (!ServerRegistrationModule.setRegistrationInfoAsync) {
throw new UnavailabilityError('ServerRegistrationModule', 'setRegistrationInfoAsync');
}
await ServerRegistrationModule.setRegistrationInfoAsync(
enabled ? JSON.stringify({ isEnabled: enabled }) : null
);
}
// note(Chmiela): This function is exported only for testing purposes.
export async function __handlePersistedRegistrationInfoAsync(
registrationInfo: string | null | undefined
) {
if (!registrationInfo) {
// No registration info, nothing to do
return;
}
let registration: DevicePushTokenRegistration | null = null;
try {
registration = JSON.parse(registrationInfo);
} catch (e) {
console.warn(
'[expo-notifications] Error encountered while fetching registration information for auto token updates.',
e
);
}
if (!registration?.isEnabled) {
// Registration is invalid or not enabled, nothing more to do
return;
}
try {
// Since the registration is enabled, fetching a "new" device token
// shouldn't be a problem.
const latestDevicePushToken = await getDevicePushTokenAsync();
await updatePushTokenAsync(latestDevicePushToken);
} catch (e) {
console.warn(
'[expo-notifications] Error encountered while updating server registration with latest device push token.',
e
);
}
}
if (ServerRegistrationModule.getRegistrationInfoAsync) {
// A global scope (to get all the updates) device push token
// subscription, never cleared.
addPushTokenListener(async (token) => {
try {
// Before updating the push token on server we always check if we should
// Since modules can't change their method availability while running, we
// can assert it's defined.
const registrationInfo = await ServerRegistrationModule.getRegistrationInfoAsync!();
if (!registrationInfo) {
// Registration is not enabled
return;
}
const registration: DevicePushTokenRegistration | null = JSON.parse(registrationInfo);
if (registration?.isEnabled) {
// Dispatch an abortable task to update
// registration with new token.
await updatePushTokenAsync(token);
}
} catch (e) {
console.warn(
'[expo-notifications] Error encountered while updating server registration with latest device push token.',
e
);
}
});
// Verify if persisted registration
// has successfully uploaded last known
// device push token. If not, retry.
ServerRegistrationModule.getRegistrationInfoAsync().then(__handlePersistedRegistrationInfoAsync);
} else {
console.warn(
`[expo-notifications] Error encountered while fetching auto-registration state, new tokens will not be automatically registered on server.`,
new UnavailabilityError('ServerRegistrationModule', 'getRegistrationInfoAsync')
);
}

View File

@@ -0,0 +1,7 @@
import { requireNativeModule } from 'expo-modules-core';
import { NotificationCategoriesModule } from './NotificationCategoriesModule.types';
export default requireNativeModule<NotificationCategoriesModule>(
'ExpoNotificationCategoriesModule'
);

View File

@@ -0,0 +1,19 @@
import { UnavailabilityError } from 'expo-modules-core';
import { NotificationCategoriesModule } from './NotificationCategoriesModule.types';
const notificationCategoriesModule: NotificationCategoriesModule = {
async getNotificationCategoriesAsync() {
return [];
},
async setNotificationCategoryAsync() {
throw new UnavailabilityError('Notifications', 'setNotificationCategoryAsync');
},
async deleteNotificationCategoryAsync() {
return false;
},
addListener() {},
removeListeners() {},
};
export default notificationCategoriesModule;

View File

@@ -0,0 +1,22 @@
import { ProxyNativeModule } from 'expo-modules-core';
import { NotificationCategory, NotificationAction } from './Notifications.types';
export interface NotificationCategoriesModule extends ProxyNativeModule {
getNotificationCategoriesAsync: () => Promise<NotificationCategory[]>;
setNotificationCategoryAsync: (
identifier: string,
actions: NotificationAction[],
options?: {
previewPlaceholder?: string;
intentIdentifiers?: string[];
categorySummaryFormat?: string;
customDismissAction?: boolean;
allowInCarPlay?: boolean;
showTitle?: boolean;
showSubtitle?: boolean;
allowAnnouncement?: boolean;
}
) => Promise<NotificationCategory>;
deleteNotificationCategoryAsync: (identifier: string) => Promise<boolean>;
}

View File

@@ -0,0 +1,7 @@
import { requireNativeModule } from 'expo-modules-core';
import { NotificationChannelGroupManager } from './NotificationChannelGroupManager.types';
export default requireNativeModule<NotificationChannelGroupManager>(
'ExpoNotificationChannelGroupManager'
);

View File

@@ -0,0 +1,6 @@
import { NotificationChannelGroupManager } from './NotificationChannelGroupManager.types';
export default {
addListener: () => {},
removeListeners: () => {},
} as NotificationChannelGroupManager;

View File

@@ -0,0 +1,34 @@
import { ProxyNativeModule } from 'expo-modules-core';
import { NotificationChannel } from './NotificationChannelManager.types';
/**
* An object represents a notification channel group.
* @platform android
*/
export interface NotificationChannelGroup {
id: string;
name: string | null;
description?: string | null;
isBlocked?: boolean;
channels: NotificationChannel[];
}
/**
* An object represents a notification channel group to be set.
* @platform android
*/
export interface NotificationChannelGroupInput {
name: string | null;
description?: string | null;
}
export interface NotificationChannelGroupManager extends ProxyNativeModule {
getNotificationChannelGroupsAsync?: () => Promise<NotificationChannelGroup[]>;
getNotificationChannelGroupAsync?: (groupId: string) => Promise<NotificationChannelGroup | null>;
setNotificationChannelGroupAsync?: (
groupId: string,
group: NotificationChannelGroupInput
) => Promise<NotificationChannelGroup | null>;
deleteNotificationChannelGroupAsync?: (groupId: string) => Promise<void>;
}

View File

@@ -0,0 +1,5 @@
import { requireNativeModule } from 'expo-modules-core';
import { NotificationChannelManager } from './NotificationChannelManager.types';
export default requireNativeModule<NotificationChannelManager>('ExpoNotificationChannelManager');

View File

@@ -0,0 +1,6 @@
import { NotificationChannelManager } from './NotificationChannelManager.types';
export default {
addListener: () => {},
removeListeners: () => {},
} as NotificationChannelManager;

View File

@@ -0,0 +1,115 @@
import { ProxyNativeModule } from 'expo-modules-core';
// @docsMissing
export enum AndroidNotificationVisibility {
UNKNOWN = 0,
PUBLIC = 1,
PRIVATE = 2,
SECRET = 3,
}
// @docsMissing
export enum AndroidAudioContentType {
UNKNOWN = 0,
SPEECH = 1,
MUSIC = 2,
MOVIE = 3,
SONIFICATION = 4,
}
// @docsMissing
export enum AndroidImportance {
UNKNOWN = 0,
UNSPECIFIED = 1,
NONE = 2,
MIN = 3,
LOW = 4,
DEFAULT = 5,
/**
* @deprecated Use `DEFAULT` instead.
*/
DEEFAULT = 5,
HIGH = 6,
MAX = 7,
}
// @docsMissing
export enum AndroidAudioUsage {
UNKNOWN = 0,
MEDIA = 1,
VOICE_COMMUNICATION = 2,
VOICE_COMMUNICATION_SIGNALLING = 3,
ALARM = 4,
NOTIFICATION = 5,
NOTIFICATION_RINGTONE = 6,
NOTIFICATION_COMMUNICATION_REQUEST = 7,
NOTIFICATION_COMMUNICATION_INSTANT = 8,
NOTIFICATION_COMMUNICATION_DELAYED = 9,
NOTIFICATION_EVENT = 10,
ASSISTANCE_ACCESSIBILITY = 11,
ASSISTANCE_NAVIGATION_GUIDANCE = 12,
ASSISTANCE_SONIFICATION = 13,
GAME = 14,
}
// @docsMissing
export interface AudioAttributes {
usage: AndroidAudioUsage;
contentType: AndroidAudioContentType;
flags: {
enforceAudibility: boolean;
requestHardwareAudioVideoSynchronization: boolean;
};
}
// We're making inner flags required to set intentionally.
// Not providing `true` for a flag makes it false, it doesn't make sense
// to let it be left undefined.
export type AudioAttributesInput = Partial<AudioAttributes>;
/**
* An object represents a notification channel.
* @platform android
*/
export interface NotificationChannel {
id: string;
name: string | null;
importance: AndroidImportance;
bypassDnd: boolean;
description: string | null;
groupId?: string | null;
lightColor: string;
lockscreenVisibility: AndroidNotificationVisibility;
showBadge: boolean;
sound: 'default' | 'custom' | null;
audioAttributes: AudioAttributes;
vibrationPattern: number[] | null;
enableLights: boolean;
enableVibrate: boolean;
}
export type RequiredBy<T, K extends keyof T> = Partial<Omit<T, K>> & Required<Pick<T, K>>;
/**
* An object represents a notification channel to be set.
* @platform android
*/
export type NotificationChannelInput = RequiredBy<
Omit<
NotificationChannel,
| 'id' // id is handled separately as a function argument
| 'audioAttributes' // need to make it AudioAttributesInput
| 'sound'
> & { audioAttributes?: AudioAttributesInput; sound?: string | null },
'name' | 'importance'
>;
export interface NotificationChannelManager extends ProxyNativeModule {
getNotificationChannelsAsync?: () => Promise<NotificationChannel[] | null>;
getNotificationChannelAsync?: (channelId: string) => Promise<NotificationChannel | null>;
setNotificationChannelAsync?: (
channelId: string,
channelConfiguration: NotificationChannelInput
) => Promise<NotificationChannel | null>;
deleteNotificationChannelAsync?: (channelId: string) => Promise<void>;
}

View File

@@ -0,0 +1,91 @@
import { createPermissionHook, Platform, UnavailabilityError } from 'expo-modules-core';
import {
NotificationPermissionsRequest,
NotificationPermissionsStatus,
} from './NotificationPermissions.types';
import NotificationPermissionsModule from './NotificationPermissionsModule';
/**
* Calling this function checks current permissions settings related to notifications.
* It lets you verify whether the app is currently allowed to display alerts, play sounds, etc.
* There is no user-facing effect of calling this.
* @return It returns a `Promise` resolving to an object represents permission settings ([`NotificationPermissionsStatus`](#notificationpermissionsstatus)).
* On iOS, make sure you [properly interpret the permissions response](#interpret-the-ios-permissions-response).
* @example Check if the app is allowed to send any type of notifications (interrupting and non-interruptingprovisional on iOS).
* ```ts
* import * as Notifications from 'expo-notifications';
*
* export async function allowsNotificationsAsync() {
* const settings = await Notifications.getPermissionsAsync();
* return (
* settings.granted || settings.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL
* );
* }
* ```
* @header permissions
*/
export async function getPermissionsAsync() {
if (!NotificationPermissionsModule.getPermissionsAsync) {
throw new UnavailabilityError('Notifications', 'getPermissionsAsync');
}
return await NotificationPermissionsModule.getPermissionsAsync();
}
/**
* Prompts the user for notification permissions according to request. **Request defaults to asking the user to allow displaying alerts,
* setting badge count and playing sounds**.
* @param permissions An object representing configuration for the request scope.
* @return It returns a Promise resolving to an object represents permission settings ([`NotificationPermissionsStatus`](#notificationpermissionsstatus)).
* On iOS, make sure you [properly interpret the permissions response](#interpret-the-ios-permissions-response).
* @example Prompts the user to allow the app to show alerts, play sounds, set badge count and let Siri read out messages through AirPods.
* ```ts
* import * as Notifications from 'expo-notifications';
*
* export function requestPermissionsAsync() {
* return await Notifications.requestPermissionsAsync({
* ios: {
* allowAlert: true,
* allowBadge: true,
* allowSound: true,
* allowAnnouncements: true,
* },
* });
* }
* ```
* @header permissions
*/
export async function requestPermissionsAsync(permissions?: NotificationPermissionsRequest) {
if (!NotificationPermissionsModule.requestPermissionsAsync) {
throw new UnavailabilityError('Notifications', 'requestPermissionsAsync');
}
const requestedPermissions = permissions ?? {
ios: {
allowAlert: true,
allowBadge: true,
allowSound: true,
},
};
const requestedPlatformPermissions = requestedPermissions[Platform.OS];
return await NotificationPermissionsModule.requestPermissionsAsync(requestedPlatformPermissions);
}
// @needsAudit
/**
* Check or request permissions to send and receive push notifications.
* This uses both `requestPermissionsAsync` and `getPermissionsAsync` to interact with the permissions.
* @example
* ```ts
* const [permissionResponse, requestPermission] = Notifications.usePermissions();
* ```
* @header permission
*/
export const usePermissions = createPermissionHook<
NotificationPermissionsStatus,
NotificationPermissionsRequest
>({
requestMethod: requestPermissionsAsync,
getMethod: getPermissionsAsync,
});

View File

@@ -0,0 +1,102 @@
import { PermissionResponse, PermissionHookOptions } from 'expo-modules-core';
export enum IosAlertStyle {
NONE = 0,
BANNER = 1,
ALERT = 2,
}
export enum IosAllowsPreviews {
NEVER = 0,
ALWAYS = 1,
WHEN_AUTHENTICATED = 2,
}
export enum IosAuthorizationStatus {
NOT_DETERMINED = 0,
DENIED = 1,
AUTHORIZED = 2,
PROVISIONAL = 3,
EPHEMERAL = 4,
}
export { PermissionHookOptions };
// @docsMissing
export interface NotificationPermissionsStatus extends PermissionResponse {
android?: {
importance: number;
interruptionFilter?: number;
};
ios?: {
status: IosAuthorizationStatus;
allowsDisplayInNotificationCenter: boolean | null;
allowsDisplayOnLockScreen: boolean | null;
allowsDisplayInCarPlay: boolean | null;
allowsAlert: boolean | null;
allowsBadge: boolean | null;
allowsSound: boolean | null;
allowsCriticalAlerts?: boolean | null;
alertStyle: IosAlertStyle;
allowsPreviews?: IosAllowsPreviews;
providesAppNotificationSettings?: boolean;
allowsAnnouncements?: boolean | null;
};
}
/**
* Available configuration for permission request on iOS platform.
* See Apple documentation for [`UNAuthorizationOptions`](https://developer.apple.com/documentation/usernotifications/unauthorizationoptions) to learn more.
*/
export interface IosNotificationPermissionsRequest {
/**
* The ability to display alerts.
*/
allowAlert?: boolean;
/**
* The ability to update the apps badge.
*/
allowBadge?: boolean;
/**
* The ability to play sounds.
*/
allowSound?: boolean;
/**
* The ability to display notifications in a CarPlay environment.
*/
allowDisplayInCarPlay?: boolean;
/**
* The ability to play sounds for critical alerts.
*/
allowCriticalAlerts?: boolean;
/**
* An option indicating the system should display a button for in-app notification settings.
*/
provideAppNotificationSettings?: boolean;
/**
* The ability to post noninterrupting notifications provisionally to the Notification Center.
*/
allowProvisional?: boolean;
/**
* The ability for Siri to automatically read out messages over AirPods.
* @deprecated
*/
allowAnnouncements?: boolean;
}
export type NativeNotificationPermissionsRequest = IosNotificationPermissionsRequest | object;
/**
* An interface representing the permissions request scope configuration.
* Each option corresponds to a different native platform authorization option.
*/
export interface NotificationPermissionsRequest {
/**
* Available configuration for permission request on iOS platform.
*/
ios?: IosNotificationPermissionsRequest;
/**
* On Android, all available permissions are granted by default, and if a user declines any permission, an app cannot prompt the user to change.
*/
android?: object;
}

View File

@@ -0,0 +1,7 @@
import { requireNativeModule } from 'expo-modules-core';
import { NotificationPermissionsModule } from './NotificationPermissionsModule.types';
export default requireNativeModule<NotificationPermissionsModule>(
'ExpoNotificationPermissionsModule'
);

View File

@@ -0,0 +1,83 @@
import { PermissionStatus, Platform } from 'expo-modules-core';
import {
NativeNotificationPermissionsRequest,
NotificationPermissionsStatus,
} from './NotificationPermissions.types';
import { NotificationPermissionsModule } from './NotificationPermissionsModule.types';
function convertPermissionStatus(
status?: NotificationPermission | 'prompt'
): NotificationPermissionsStatus {
switch (status) {
case 'granted':
return {
status: PermissionStatus.GRANTED,
expires: 'never',
canAskAgain: false,
granted: true,
};
case 'denied':
return {
status: PermissionStatus.DENIED,
expires: 'never',
canAskAgain: false,
granted: false,
};
default:
return {
status: PermissionStatus.UNDETERMINED,
expires: 'never',
canAskAgain: true,
granted: false,
};
}
}
async function resolvePermissionAsync({
shouldAsk,
}: {
shouldAsk: boolean;
}): Promise<NotificationPermissionsStatus> {
if (!Platform.isDOMAvailable) {
return convertPermissionStatus('denied');
}
const { Notification = {} } = window as any;
if (typeof Notification.requestPermission !== 'undefined') {
let status = Notification.permission;
if (shouldAsk) {
status = await new Promise((resolve, reject) => {
let resolved = false;
function resolveOnce(status: string) {
if (!resolved) {
resolved = true;
resolve(status);
}
}
// Some browsers require a callback argument and some return a Promise
Notification.requestPermission(resolveOnce)?.then(resolveOnce)?.catch(reject);
});
}
return convertPermissionStatus(status);
} else if (typeof navigator !== 'undefined' && navigator?.permissions?.query) {
// TODO(Bacon): Support `push` in the future when it's stable.
const query = await navigator.permissions.query({ name: 'notifications' });
return convertPermissionStatus(query.state);
}
// Platforms like iOS Safari don't support Notifications so return denied.
return convertPermissionStatus('denied');
}
export default {
addListener: () => {},
removeListeners: () => {},
async getPermissionsAsync(): Promise<NotificationPermissionsStatus> {
return resolvePermissionAsync({ shouldAsk: false });
},
async requestPermissionsAsync(
request: NativeNotificationPermissionsRequest
): Promise<NotificationPermissionsStatus> {
return resolvePermissionAsync({ shouldAsk: true });
},
} as NotificationPermissionsModule;

View File

@@ -0,0 +1,13 @@
import { ProxyNativeModule } from 'expo-modules-core';
import {
NotificationPermissionsStatus,
NativeNotificationPermissionsRequest,
} from './NotificationPermissions.types';
export interface NotificationPermissionsModule extends ProxyNativeModule {
getPermissionsAsync?: () => Promise<NotificationPermissionsStatus>;
requestPermissionsAsync?: (
request: NativeNotificationPermissionsRequest
) => Promise<NotificationPermissionsStatus>;
}

View File

@@ -0,0 +1,5 @@
import { requireNativeModule } from 'expo-modules-core';
import { NotificationPresenterModule } from './NotificationPresenterModule.types';
export default requireNativeModule<NotificationPresenterModule>('ExpoNotificationPresenter');

View File

@@ -0,0 +1,6 @@
import { NotificationPresenterModule } from './NotificationPresenterModule.types';
export default {
addListener: () => {},
removeListeners: () => {},
} as NotificationPresenterModule;

View File

@@ -0,0 +1,13 @@
import { ProxyNativeModule } from 'expo-modules-core';
import { Notification, NotificationContentInput } from './Notifications.types';
export interface NotificationPresenterModule extends ProxyNativeModule {
getPresentedNotificationsAsync?: () => Promise<Notification[]>;
presentNotificationAsync?: (
identifier: string,
content: NotificationContentInput
) => Promise<string>;
dismissNotificationAsync?: (identifier: string) => Promise<void>;
dismissAllNotificationsAsync?: () => Promise<void>;
}

View File

@@ -0,0 +1,5 @@
import { requireNativeModule } from 'expo-modules-core';
import { NotificationSchedulerModule } from './NotificationScheduler.types';
export default requireNativeModule<NotificationSchedulerModule>('ExpoNotificationScheduler');

View File

@@ -0,0 +1,6 @@
import { NotificationSchedulerModule } from './NotificationScheduler.types';
export default {
addListener: () => {},
removeListeners: () => {},
} as NotificationSchedulerModule;

View File

@@ -0,0 +1,79 @@
import { ProxyNativeModule } from 'expo-modules-core';
import {
NotificationRequest,
NotificationContentInput,
CalendarTriggerInputValue,
} from './Notifications.types';
export interface NotificationSchedulerModule extends ProxyNativeModule {
getAllScheduledNotificationsAsync?: () => Promise<NotificationRequest[]>;
scheduleNotificationAsync?: (
identifier: string,
notificationContent: NotificationContentInput,
trigger: NotificationTriggerInput
) => Promise<string>;
cancelScheduledNotificationAsync?: (identifier: string) => Promise<void>;
cancelAllScheduledNotificationsAsync?: () => Promise<void>;
getNextTriggerDateAsync?: (trigger: NotificationTriggerInput) => Promise<number>;
}
export interface ChannelAwareTriggerInput {
type: 'channel';
channelId?: string;
}
// ISO8601 calendar pattern-matching
export interface CalendarTriggerInput {
type: 'calendar';
channelId?: string;
repeats?: boolean;
value: CalendarTriggerInputValue;
}
export interface TimeIntervalTriggerInput {
type: 'timeInterval';
channelId?: string;
repeats: boolean;
seconds: number;
}
export interface DailyTriggerInput {
type: 'daily';
channelId?: string;
hour: number;
minute: number;
}
export interface WeeklyTriggerInput {
type: 'weekly';
channelId?: string;
weekday: number;
hour: number;
minute: number;
}
export interface YearlyTriggerInput {
type: 'yearly';
channelId?: string;
day: number;
month: number;
hour: number;
minute: number;
}
export interface DateTriggerInput {
type: 'date';
channelId?: string;
timestamp: number; // seconds since 1970
}
export type NotificationTriggerInput =
| null
| ChannelAwareTriggerInput
| DateTriggerInput
| CalendarTriggerInput
| TimeIntervalTriggerInput
| DailyTriggerInput
| WeeklyTriggerInput
| YearlyTriggerInput;

View File

@@ -0,0 +1,720 @@
import type {
PermissionExpiration,
PermissionResponse,
PermissionStatus,
Subscription,
} from 'expo-modules-core';
/**
* An object represents a notification delivered by a push notification system.
*
* On Android under `remoteMessage` field a JS version of the Firebase `RemoteMessage` may be accessed.
* On iOS under `payload` you may find full contents of [`UNNotificationContent`'s](https://developer.apple.com/documentation/usernotifications/unnotificationcontent?language=objc) [`userInfo`](https://developer.apple.com/documentation/usernotifications/unnotificationcontent/1649869-userinfo?language=objc), for example [remote notification payload](https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html)
* On web there is no extra data.
*/
export type PushNotificationTrigger = {
type: 'push';
/**
* @platform ios
*/
payload?: Record<string, unknown>;
/**
* @platform android
*/
remoteMessage?: FirebaseRemoteMessage;
};
/**
* A trigger related to a [`UNCalendarNotificationTrigger`](https://developer.apple.com/documentation/usernotifications/uncalendarnotificationtrigger?language=objc).
* @platform ios
*/
export interface CalendarNotificationTrigger {
type: 'calendar';
repeats: boolean;
dateComponents: {
era?: number;
year?: number;
month?: number;
day?: number;
hour?: number;
minute?: number;
second?: number;
weekday?: number;
weekdayOrdinal?: number;
quarter?: number;
weekOfMonth?: number;
weekOfYear?: number;
yearForWeekOfYear?: number;
nanosecond?: number;
isLeapMonth: boolean;
timeZone?: string;
calendar?: string;
};
}
/**
* The region used to determine when the system sends the notification.
* @platform ios
*/
export interface Region {
type: string;
/**
* The identifier for the region object.
*/
identifier: string;
/**
* A Boolean indicating that notifications are generated upon entry into the region.
*/
notifyOnEntry: boolean;
/**
* A Boolean indicating that notifications are generated upon exit from the region.
*/
notifyOnExit: boolean;
}
/**
* A circular geographic region, specified as a center point and radius. Based on Core Location [`CLCircularRegion`](https://developer.apple.com/documentation/corelocation/clcircularregion) class.
* @platform ios
*/
export interface CircularRegion extends Region {
type: 'circular';
/**
* The radius (measured in meters) that defines the geographic areas outer boundary.
*/
radius: number;
/**
* The center point of the geographic area.
*/
center: {
latitude: number;
longitude: number;
};
}
/**
* A region used to detect the presence of iBeacon devices. Based on Core Location [`CLBeaconRegion`](https://developer.apple.com/documentation/corelocation/clbeaconregion) class.
* @platform ios
*/
export interface BeaconRegion extends Region {
type: 'beacon';
/**
* A Boolean value that indicates whether Core Location sends beacon notifications when the devices display is on.
*/
notifyEntryStateOnDisplay: boolean;
/**
* The major value from the beacon identity constraint that defines the beacon region.
*/
major: number | null;
/**
* The minor value from the beacon identity constraint that defines the beacon region.
*/
minor: number | null;
/**
* The UUID value from the beacon identity constraint that defines the beacon region.
*/
uuid?: string;
/**
* The beacon identity constraint that defines the beacon region.
*/
beaconIdentityConstraint?: {
uuid: string;
major: number | null;
minor: number | null;
};
}
/**
* A trigger related to a [`UNLocationNotificationTrigger`](https://developer.apple.com/documentation/usernotifications/unlocationnotificationtrigger?language=objc).
* @platform ios
*/
export interface LocationNotificationTrigger {
type: 'location';
repeats: boolean;
region: CircularRegion | BeaconRegion;
}
/**
* A trigger related to an elapsed time interval. May be repeating (see `repeats` field).
*/
export interface TimeIntervalNotificationTrigger {
type: 'timeInterval';
repeats: boolean;
seconds: number;
}
/**
* A trigger related to a daily notification.
* > The same functionality will be achieved on iOS with a `CalendarNotificationTrigger`.
* @platform android
*/
export interface DailyNotificationTrigger {
type: 'daily';
hour: number;
minute: number;
}
/**
* A trigger related to a weekly notification.
* > The same functionality will be achieved on iOS with a `CalendarNotificationTrigger`.
* @platform android
*/
export interface WeeklyNotificationTrigger {
type: 'weekly';
weekday: number;
hour: number;
minute: number;
}
/**
* A trigger related to a yearly notification.
* > The same functionality will be achieved on iOS with a `CalendarNotificationTrigger`.
* @platform android
*/
export interface YearlyNotificationTrigger {
type: 'yearly';
day: number;
month: number;
hour: number;
minute: number;
}
// @docsMissing
/**
* A Firebase `RemoteMessage` that caused the notification to be delivered to the app.
*/
export interface FirebaseRemoteMessage {
collapseKey: string | null;
data: Record<string, string>;
from: string | null;
messageId: string | null;
messageType: string | null;
originalPriority: number;
priority: number;
sentTime: number;
to: string | null;
ttl: number;
notification: null | FirebaseRemoteMessageNotification;
}
// @docsMissing
export interface FirebaseRemoteMessageNotification {
body: string | null;
bodyLocalizationArgs: string[] | null;
bodyLocalizationKey: string | null;
channelId: string | null;
clickAction: string | null;
color: string | null;
usesDefaultLightSettings: boolean;
usesDefaultSound: boolean;
usesDefaultVibrateSettings: boolean;
eventTime: number | null;
icon: string | null;
imageUrl: string | null;
lightSettings: number[] | null;
link: string | null;
localOnly: boolean;
notificationCount: number | null;
notificationPriority: number | null;
sound: string | null;
sticky: boolean;
tag: string | null;
ticker: string | null;
title: string | null;
titleLocalizationArgs: string[] | null;
titleLocalizationKey: string | null;
vibrateTimings: number[] | null;
visibility: number | null;
}
/**
* Represents a notification trigger that is unknown to `expo-notifications` and that it didn't know how to serialize for JS.
*/
export interface UnknownNotificationTrigger {
type: 'unknown';
}
/**
* A union type containing different triggers which may cause the notification to be delivered to the application.
*/
export type NotificationTrigger =
| PushNotificationTrigger
| CalendarNotificationTrigger
| LocationNotificationTrigger
| TimeIntervalNotificationTrigger
| DailyNotificationTrigger
| WeeklyNotificationTrigger
| YearlyNotificationTrigger
| UnknownNotificationTrigger;
/**
* A trigger that will cause the notification to be delivered immediately.
*/
export type ChannelAwareTriggerInput = {
channelId: string;
};
// @docsMissing
export type CalendarTriggerInputValue = {
timezone?: string;
year?: number;
month?: number;
weekday?: number;
weekOfMonth?: number;
weekOfYear?: number;
weekdayOrdinal?: number;
day?: number;
hour?: number;
minute?: number;
second?: number;
};
/**
* A trigger that will cause the notification to be delivered once or many times when the date components match the specified values.
* Corresponds to native [`UNCalendarNotificationTrigger`](https://developer.apple.com/documentation/usernotifications/uncalendarnotificationtrigger?language=objc).
* @platform ios
*/
export type CalendarTriggerInput = CalendarTriggerInputValue & {
channelId?: string;
repeats?: boolean;
};
/**
* A trigger that will cause the notification to be delivered once or many times (depends on the `repeats` field) after `seconds` time elapse.
* > **On iOS**, when `repeats` is `true`, the time interval must be 60 seconds or greater. Otherwise, the notification won't be triggered.
*/
export interface TimeIntervalTriggerInput {
channelId?: string;
repeats?: boolean;
seconds: number;
}
/**
* A trigger that will cause the notification to be delivered once per day.
*/
export interface DailyTriggerInput {
channelId?: string;
hour: number;
minute: number;
repeats: true;
}
/**
* A trigger that will cause the notification to be delivered once every week.
* > **Note:** Weekdays are specified with a number from `1` through `7`, with `1` indicating Sunday.
*/
export interface WeeklyTriggerInput {
channelId?: string;
weekday: number;
hour: number;
minute: number;
repeats: true;
}
/**
* A trigger that will cause the notification to be delivered once every year.
* > **Note:** all properties are specified in JavaScript Date's ranges.
*/
export interface YearlyTriggerInput {
channelId?: string;
day: number;
month: number;
hour: number;
minute: number;
repeats: true;
}
/**
* A trigger that will cause the notification to be delivered once at the specified `Date`.
* If you pass in a `number` it will be interpreted as a Unix timestamp.
*/
export type DateTriggerInput = Date | number | { channelId?: string; date: Date | number };
/**
* A type represents time-based, schedulable triggers. For these triggers you can check the next trigger date
* with [`getNextTriggerDateAsync`](#notificationsgetnexttriggerdateasynctrigger).
*/
export type SchedulableNotificationTriggerInput =
| DateTriggerInput
| TimeIntervalTriggerInput
| DailyTriggerInput
| WeeklyTriggerInput
| YearlyTriggerInput
| CalendarTriggerInput;
/**
* A type represents possible triggers with which you can schedule notifications.
* A `null` trigger means that the notification should be scheduled for delivery immediately.
*/
export type NotificationTriggerInput =
| null
| ChannelAwareTriggerInput
| SchedulableNotificationTriggerInput;
/**
* An enum corresponding to values appropriate for Android's [`Notification#priority`](https://developer.android.com/reference/android/app/Notification#priority) field.
*/
export enum AndroidNotificationPriority {
MIN = 'min',
LOW = 'low',
DEFAULT = 'default',
HIGH = 'high',
MAX = 'max',
}
/**
* An object represents notification's content.
*/
export type NotificationContent = {
/**
* Notification title - the bold text displayed above the rest of the content.
*/
title: string | null;
/**
* On Android: `subText` - the display depends on the device.
*
* On iOS: `subtitle` - the bold text displayed between title and the rest of the content.
*/
subtitle: string | null;
/**
* Notification body - the main content of the notification.
*/
body: string | null;
/**
* Data associated with the notification, not displayed
*/
data: Record<string, any>;
// @docsMissing
sound: 'default' | 'defaultCritical' | 'custom' | null;
} & (NotificationContentIos | NotificationContentAndroid);
/**
* See [Apple documentation](https://developer.apple.com/documentation/usernotifications/unnotificationcontent?language=objc) for more information on specific fields.
*/
export type NotificationContentIos = {
/**
* The name of the image or storyboard to use when your app launches because of the notification.
*/
launchImageName: string | null;
/**
* The number that your apps icon displays.
*/
badge: number | null;
/**
* The visual and audio attachments to display alongside the notifications main content.
*/
attachments: NotificationContentAttachmentIos[];
/**
* The text the system adds to the notification summary to provide additional context.
*/
summaryArgument?: string | null;
/**
* The number the system adds to the notification summary when the notification represents multiple items.
*/
summaryArgumentCount?: number;
/**
* The identifier of the notifications category.
*/
categoryIdentifier: string | null;
/**
* The identifier that groups related notifications.
*/
threadIdentifier: string | null;
/**
* The value your app uses to determine which scene to display to handle the notification.
*/
targetContentIdentifier?: string;
/*
* The notifications importance and required delivery timing.
* Posible values:
* - 'passive' - the system adds the notification to the notification list without lighting up the screen or playing a sound
* - 'active' - the system presents the notification immediately, lights up the screen, and can play a sound
* - 'timeSensitive' - The system presents the notification immediately, lights up the screen, can play a sound, and breaks through system notification controls
* - 'critical - the system presents the notification immediately, lights up the screen, and bypasses the mute switch to play a sound
* @platform ios 15+
*/
interruptionLevel?: 'passive' | 'active' | 'timeSensitive' | 'critical';
};
// @docsMissing
/**
* @platform ios
*/
export type NotificationContentAttachmentIos = {
identifier: string | null;
url: string | null;
type: string | null;
typeHint?: string;
hideThumbnail?: boolean;
thumbnailClipArea?: { x: number; y: number; width: number; height: number };
thumbnailTime?: number;
};
/**
* See [Android developer documentation](https://developer.android.com/reference/android/app/Notification#fields) for more information on specific fields.
*/
export type NotificationContentAndroid = {
/**
* Application badge number associated with the notification.
*/
badge?: number;
/**
* Accent color (in `#AARRGGBB` or `#RRGGBB` format) to be applied by the standard Style templates when presenting this notification.
*/
color?: string;
/**
* Relative priority for this notification. Priority is an indication of how much of the user's valuable attention should be consumed by this notification.
* Low-priority notifications may be hidden from the user in certain situations, while the user might be interrupted for a higher-priority notification.
* The system will make a determination about how to interpret this priority when presenting the notification.
*/
priority?: AndroidNotificationPriority;
/**
* The pattern with which to vibrate.
*/
vibrationPattern?: number[];
};
/**
* An object represents a request to present a notification. It has content — how it's being represented, and a trigger — what triggers the notification.
* Many notifications ([`Notification`](#notification)) may be triggered with the same request (for example, a repeating notification).
*/
export interface NotificationRequest {
identifier: string;
content: NotificationContent;
trigger: NotificationTrigger;
}
// TODO(simek): asses if we can base this type on `NotificationContent`, since most of the fields looks like repetition
/**
* An object represents notification content that you pass in to `presentNotificationAsync` or as a part of `NotificationRequestInput`.
*/
export type NotificationContentInput = {
/**
* Notification title - the bold text displayed above the rest of the content.
*/
title?: string | null;
/**
* On Android: `subText` - the display depends on the device.
*
* On iOS: `subtitle` - the bold text displayed between title and the rest of the content.
*/
subtitle?: string | null;
/**
* The main content of the notification.
*/
body?: string | null;
/**
* Data associated with the notification, not displayed.
*/
data?: Record<string, any>;
/**
* Application badge number associated with the notification.
*/
badge?: number;
sound?: boolean | string;
/**
* The name of the image or storyboard to use when your app launches because of the notification.
*/
launchImageName?: string;
/**
* The pattern with which to vibrate.
* @platform android
*/
vibrate?: number[];
/**
* Relative priority for this notification. Priority is an indication of how much of the user's valuable attention should be consumed by this notification.
* Low-priority notifications may be hidden from the user in certain situations, while the user might be interrupted for a higher-priority notification.
* The system will make a determination about how to interpret this priority when presenting the notification.
* @platform android
*/
priority?: string;
/**
* Accent color (in `#AARRGGBB` or `#RRGGBB` format) to be applied by the standard Style templates when presenting this notification.
* @platform android
*/
color?: string;
/**
* If set to `false`, the notification will not be automatically dismissed when clicked.
* The setting will be used when the value is not provided or is invalid is set to `true`, and the notification
* will be dismissed automatically anyway. Corresponds directly to Android's `setAutoCancel` behavior.
*
* See [Android developer documentation](https://developer.android.com/reference/android/app/Notification.Builder#setAutoCancel(boolean))
* for more details.
* @platform android
*/
autoDismiss?: boolean;
/**
* The identifier of the notifications category.
* @platform ios
*/
categoryIdentifier?: string;
/**
* If set to `true`, the notification cannot be dismissed by swipe. This setting defaults
* to `false` if not provided or is invalid. Corresponds directly do Android's `isOngoing` behavior.
* In Firebase terms this property of a notification is called `sticky`.
*
* See [Android developer documentation](https://developer.android.com/reference/android/app/Notification.Builder#setOngoing(boolean))
* and [Firebase documentation](https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#AndroidNotification.FIELDS.sticky)
* for more details.
* @platform android
*/
sticky?: boolean;
/**
* The visual and audio attachments to display alongside the notifications main content.
* @platform ios
*/
attachments?: NotificationContentAttachmentIos[];
/*
* The notifications importance and required delivery timing.
* Posible values:
* - 'passive' - the system adds the notification to the notification list without lighting up the screen or playing a sound
* - 'active' - the system presents the notification immediately, lights up the screen, and can play a sound
* - 'timeSensitive' - The system presents the notification immediately, lights up the screen, can play a sound, and breaks through system notification controls
* - 'critical - the system presents the notification immediately, lights up the screen, and bypasses the mute switch to play a sound
* @platform ios 15+
*/
interruptionLevel?: 'passive' | 'active' | 'timeSensitive' | 'critical';
};
/**
* An object represents a notification request you can pass into `scheduleNotificationAsync`.
*/
export interface NotificationRequestInput {
identifier?: string;
content: NotificationContentInput;
trigger: NotificationTriggerInput;
}
/**
* An object represents a single notification that has been triggered by some request ([`NotificationRequest`](#notificationrequest)) at some point in time.
*/
export interface Notification {
date: number;
request: NotificationRequest;
}
/**
* An object represents user's interaction with the notification.
* > **Note:** If the user taps on a notification `actionIdentifier` will be equal to [`Notifications.DEFAULT_ACTION_IDENTIFIER`](#notificationsdefault_action_identifier).
*/
export interface NotificationResponse {
notification: Notification;
actionIdentifier: string;
userText?: string;
}
/**
* An object represents behavior that should be applied to the incoming notification.
* > On Android, setting `shouldPlaySound: false` will result in the drop-down notification alert **not** showing, no matter what the priority is.
* > This setting will also override any channel-specific sounds you may have configured.
*/
export interface NotificationBehavior {
shouldShowAlert: boolean;
shouldPlaySound: boolean;
shouldSetBadge: boolean;
priority?: AndroidNotificationPriority;
}
export interface NotificationAction {
/**
* A unique string that identifies this action. If a user takes this action (for example, selects this button in the system's Notification UI),
* your app will receive this `actionIdentifier` via the [`NotificationResponseReceivedListener`](#addnotificationresponsereceivedlistenerlistener).
*/
identifier: string;
/**
* The title of the button triggering this action.
*/
buttonTitle: string;
/**
* Object which, if provided, will result in a button that prompts the user for a text response.
*/
textInput?: {
/**
* A string which will be used as the title for the button used for submitting the text response.
* @platform ios
*/
submitButtonTitle: string;
/**
* A string that serves as a placeholder until the user begins typing. Defaults to no placeholder string.
*/
placeholder: string;
};
/**
* Object representing the additional configuration options.
*/
options?: {
/**
* Boolean indicating whether the button title will be highlighted a different color (usually red).
* This usually signifies a destructive action such as deleting data.
* @platform ios
*/
isDestructive?: boolean;
/**
* Boolean indicating whether triggering the action will require authentication from the user.
* @platform ios
*/
isAuthenticationRequired?: boolean;
/**
* Boolean indicating whether triggering this action foregrounds the app.
* If `false` and your app is killed (not just backgrounded), [`NotificationResponseReceived` listeners](#addnotificationresponsereceivedlistenerlistener)
* will not be triggered when a user selects this action.
* @default true
*/
opensAppToForeground?: boolean;
};
}
// @docsMissing
export interface NotificationCategory {
identifier: string;
actions: NotificationAction[];
options?: NotificationCategoryOptions;
}
/**
* @platform ios
*/
export type NotificationCategoryOptions = {
/**
* Customizable placeholder for the notification preview text. This is shown if the user has disabled notification previews for the app.
* Defaults to the localized iOS system default placeholder (`Notification`).
*/
previewPlaceholder?: string;
/**
* Array of [Intent Class Identifiers](https://developer.apple.com/documentation/sirikit/intent_class_identifiers). When a notification is delivered,
* the presence of an intent identifier lets the system know that the notification is potentially related to the handling of a request made through Siri.
* @default []
*/
intentIdentifiers?: string[];
/**
* A format string for the summary description used when the system groups the categorys notifications.
*/
categorySummaryFormat?: string;
/**
* A boolean indicating whether to send actions for handling when the notification is dismissed (the user must explicitly dismiss
* the notification interface - ignoring a notification or flicking away a notification banner does not trigger this action).
* @default false
*/
customDismissAction?: boolean;
/**
* A boolean indicating whether to allow CarPlay to display notifications of this type. **Apps must be approved for CarPlay to make use of this feature.**
* @default false
*/
allowInCarPlay?: boolean;
/**
* A boolean indicating whether to show the notification's title, even if the user has disabled notification previews for the app.
* @default false
*/
showTitle?: boolean;
/**
* A boolean indicating whether to show the notification's subtitle, even if the user has disabled notification previews for the app.
* @default false
*/
showSubtitle?: boolean;
/**
* A boolean indicating whether to allow notifications to be automatically read by Siri when the user is using AirPods.
* @default false
*/
allowAnnouncement?: boolean;
};
export type { Subscription, PermissionResponse, PermissionStatus, PermissionExpiration };

View File

@@ -0,0 +1,154 @@
import { EventEmitter, Subscription, UnavailabilityError } from 'expo-modules-core';
import { Notification, NotificationResponse } from './Notifications.types';
import NotificationsEmitterModule from './NotificationsEmitterModule';
import { mapNotification, mapNotificationResponse } from './utils/mapNotificationResponse';
// Web uses SyntheticEventEmitter
const emitter = new EventEmitter(NotificationsEmitterModule);
const didReceiveNotificationEventName = 'onDidReceiveNotification';
const didDropNotificationsEventName = 'onNotificationsDeleted';
const didReceiveNotificationResponseEventName = 'onDidReceiveNotificationResponse';
const didClearNotificationResponseEventName = 'onDidClearNotificationResponse';
// @docsMissing
export const DEFAULT_ACTION_IDENTIFIER = 'expo.modules.notifications.actions.DEFAULT';
/**
* Listeners registered by this method will be called whenever a notification is received while the app is running.
* @param listener A function accepting a notification ([`Notification`](#notification)) as an argument.
* @return A [`Subscription`](#subscription) object represents the subscription of the provided listener.
* @example Registering a notification listener using a React hook:
* ```jsx
* import React from 'react';
* import * as Notifications from 'expo-notifications';
*
* export default function App() {
* React.useEffect(() => {
* const subscription = Notifications.addNotificationReceivedListener(notification => {
* console.log(notification);
* });
* return () => subscription.remove();
* }, []);
*
* return (
* // Your app content
* );
* }
* ```
* @header listen
*/
export function addNotificationReceivedListener(
listener: (event: Notification) => void
): Subscription {
return emitter.addListener<Notification>(
didReceiveNotificationEventName,
(notification: Notification) => {
const mappedNotification = mapNotification(notification);
listener(mappedNotification);
}
);
}
/**
* Listeners registered by this method will be called whenever some notifications have been dropped by the server.
* Applicable only to Firebase Cloud Messaging which we use as a notifications service on Android. It corresponds to `onDeletedMessages()` callback.
* More information can be found in [Firebase docs](https://firebase.google.com/docs/cloud-messaging/android/receive#override-ondeletedmessages).
* @param listener A callback function.
* @return A [`Subscription`](#subscription) object represents the subscription of the provided listener.
* @header listen
*/
export function addNotificationsDroppedListener(listener: () => void): Subscription {
return emitter.addListener<void>(didDropNotificationsEventName, listener);
}
/**
* Listeners registered by this method will be called whenever a user interacts with a notification (for example, taps on it).
* @param listener A function accepting notification response ([`NotificationResponse`](#notificationresponse)) as an argument.
* @return A [`Subscription`](#subscription) object represents the subscription of the provided listener.
* @example Register a notification responder listener:
* ```jsx
* import React from 'react';
* import { Linking } from 'react-native';
* import * as Notifications from 'expo-notifications';
*
* export default function Container() {
* React.useEffect(() => {
* const subscription = Notifications.addNotificationResponseReceivedListener(response => {
* const url = response.notification.request.content.data.url;
* Linking.openURL(url);
* });
* return () => subscription.remove();
* }, []);
*
* return (
* // Your app content
* );
* }
* ```
* @header listen
*/
export function addNotificationResponseReceivedListener(
listener: (event: NotificationResponse) => void
): Subscription {
return emitter.addListener<NotificationResponse>(
didReceiveNotificationResponseEventName,
(response: NotificationResponse) => {
const mappedResponse = mapNotificationResponse(response);
listener(mappedResponse);
}
);
}
/**
* Removes a notification subscription returned by an `addNotificationListener` call.
* @param subscription A subscription returned by `addNotificationListener` method.
* @header listen
*/
export function removeNotificationSubscription(subscription: Subscription) {
emitter.removeSubscription(subscription);
}
/**
* Gets the notification response that was received most recently
* (a notification response designates an interaction with a notification, such as tapping on it).
*
* - `null` - if no notification response has been received yet
* - a [`NotificationResponse`](#notificationresponse) object - if a notification response was received
* - a [`NotificationResponse`](#notificationresponse) object - if a notification response was received.
*/
export async function getLastNotificationResponseAsync(): Promise<NotificationResponse | null> {
if (!NotificationsEmitterModule.getLastNotificationResponseAsync) {
throw new UnavailabilityError('ExpoNotifications', 'getLastNotificationResponseAsync');
}
const response = await NotificationsEmitterModule.getLastNotificationResponseAsync();
const mappedResponse = response ? mapNotificationResponse(response) : response;
return mappedResponse;
}
/* Clears the notification response that was received most recently. May be used
* when an app selects a route based on the notification response, and it is undesirable
* to continue selecting the route after the response has already been handled.
* to continue to select the route after the response has already been handled.
*
* If a component is using the [`useLastNotificationResponse`](#useLastNotificationResponse) hook,
* this call will also clear the value returned by the hook.
*
* @return A promise that resolves if the native call was successful.
*/
export async function clearLastNotificationResponseAsync(): Promise<void> {
if (!NotificationsEmitterModule.clearLastNotificationResponseAsync) {
throw new UnavailabilityError('ExpoNotifications', 'getLastNotificationResponseAsync');
}
await NotificationsEmitterModule.clearLastNotificationResponseAsync();
// Emit event to clear any useLastNotificationResponse hooks, after native call succeeds
emitter.emit(didClearNotificationResponseEventName, []);
}
/**
* @hidden
*/
export function addNotificationResponseClearedListener(listener: () => void): Subscription {
return emitter.addListener<void>(didClearNotificationResponseEventName, listener);
}

View File

@@ -0,0 +1,5 @@
import { requireNativeModule } from 'expo-modules-core';
import { NotificationsEmitterModule } from './NotificationsEmitterModule.types';
export default requireNativeModule<NotificationsEmitterModule>('ExpoNotificationsEmitter');

View File

@@ -0,0 +1,17 @@
import { Platform } from 'expo-modules-core';
import { NotificationsEmitterModule } from './NotificationsEmitterModule.types';
let warningHasBeenShown = false;
export default {
addListener: () => {
if (!warningHasBeenShown) {
console.warn(
`[expo-notifications] Emitting notifications is not yet fully supported on ${Platform.OS}. Adding a listener will have no effect.`
);
warningHasBeenShown = true;
}
},
removeListeners: () => {},
} as NotificationsEmitterModule;

View File

@@ -0,0 +1,7 @@
import { ProxyNativeModule } from 'expo-modules-core';
import { NotificationResponse } from './Notifications.types';
export interface NotificationsEmitterModule extends ProxyNativeModule {
getLastNotificationResponseAsync?: () => Promise<NotificationResponse | null>;
}

View File

@@ -0,0 +1,120 @@
import { EventEmitter, Subscription, CodedError, UnavailabilityError } from 'expo-modules-core';
import { Notification, NotificationBehavior } from './Notifications.types';
import NotificationsHandlerModule from './NotificationsHandlerModule';
/**
* @hidden
*/
export class NotificationTimeoutError extends CodedError {
info: { notification: Notification; id: string };
constructor(notificationId: string, notification: Notification) {
super('ERR_NOTIFICATION_TIMEOUT', `Notification handling timed out for ID ${notificationId}.`);
this.info = { id: notificationId, notification };
}
}
// @docsMissing
export type NotificationHandlingError = NotificationTimeoutError | Error;
export interface NotificationHandler {
/**
* A function accepting an incoming notification returning a `Promise` resolving to a behavior ([`NotificationBehavior`](#notificationbehavior))
* applicable to the notification
* @param notification An object representing the notification.
*/
handleNotification: (notification: Notification) => Promise<NotificationBehavior>;
/**
* A function called whenever an incoming notification is handled successfully.
* @param notificationId Identifier of the notification.
*/
handleSuccess?: (notificationId: string) => void;
/**
* A function called whenever handling of an incoming notification fails.
* @param notificationId Identifier of the notification.
* @param error An error which occurred in form of `NotificationHandlingError` object.
*/
handleError?: (notificationId: string, error: NotificationHandlingError) => void;
}
type HandleNotificationEvent = {
id: string;
notification: Notification;
};
type HandleNotificationTimeoutEvent = HandleNotificationEvent;
// Web uses SyntheticEventEmitter
const notificationEmitter = new EventEmitter(NotificationsHandlerModule);
const handleNotificationEventName = 'onHandleNotification';
const handleNotificationTimeoutEventName = 'onHandleNotificationTimeout';
let handleSubscription: Subscription | null = null;
let handleTimeoutSubscription: Subscription | null = null;
/**
* When a notification is received while the app is running, using this function you can set a callback that will decide
* whether the notification should be shown to the user or not.
*
* When a notification is received, `handleNotification` is called with the incoming notification as an argument.
* The function should respond with a behavior object within 3 seconds, otherwise, the notification will be discarded.
* If the notification is handled successfully, `handleSuccess` is called with the identifier of the notification,
* otherwise (or on timeout) `handleError` will be called.
*
* The default behavior when the handler is not set or does not respond in time is not to show the notification.
* @param handler A single parameter which should be either `null` (if you want to clear the handler) or a [`NotificationHandler`](#notificationhandler) object.
*
* @example Implementing a notification handler that always shows the notification when it is received.
* ```jsx
* import * as Notifications from 'expo-notifications';
*
* Notifications.setNotificationHandler({
* handleNotification: async () => ({
* shouldShowAlert: true,
* shouldPlaySound: false,
* shouldSetBadge: false,
* }),
* });
* ```
* @header inForeground
*/
export function setNotificationHandler(handler: NotificationHandler | null): void {
if (handleSubscription) {
handleSubscription.remove();
handleSubscription = null;
}
if (handleTimeoutSubscription) {
handleTimeoutSubscription.remove();
handleTimeoutSubscription = null;
}
if (handler) {
handleSubscription = notificationEmitter.addListener<HandleNotificationEvent>(
handleNotificationEventName,
async ({ id, notification }) => {
if (!NotificationsHandlerModule.handleNotificationAsync) {
handler.handleError?.(
id,
new UnavailabilityError('Notifications', 'handleNotificationAsync')
);
return;
}
try {
const behavior = await handler.handleNotification(notification);
await NotificationsHandlerModule.handleNotificationAsync(id, behavior);
handler.handleSuccess?.(id);
} catch (error) {
handler.handleError?.(id, error);
}
}
);
handleTimeoutSubscription = notificationEmitter.addListener<HandleNotificationTimeoutEvent>(
handleNotificationTimeoutEventName,
({ id, notification }) =>
handler.handleError?.(id, new NotificationTimeoutError(id, notification))
);
}
}

View File

@@ -0,0 +1,5 @@
import { requireNativeModule } from 'expo-modules-core';
import { NotificationsHandlerModule } from './NotificationsHandlerModule.types';
export default requireNativeModule<NotificationsHandlerModule>('ExpoNotificationsHandlerModule');

View File

@@ -0,0 +1,17 @@
import { Platform } from 'expo-modules-core';
import { NotificationsHandlerModule } from './NotificationsHandlerModule.types';
let warningHasBeenShown = false;
export default {
addListener: () => {
if (!warningHasBeenShown) {
console.warn(
`[expo-notifications] Notifications handling is not yet fully supported on ${Platform.OS}. Handling notifications will have no effect.`
);
warningHasBeenShown = true;
}
},
removeListeners: () => {},
} as NotificationsHandlerModule;

View File

@@ -0,0 +1,10 @@
import { ProxyNativeModule } from 'expo-modules-core';
import { NotificationBehavior } from './Notifications.types';
export interface NotificationsHandlerModule extends ProxyNativeModule {
handleNotificationAsync?: (
notificationId: string,
notificationBehavior: NotificationBehavior
) => Promise<void>;
}

View File

@@ -0,0 +1,5 @@
import { requireNativeModule } from 'expo-modules-core';
import { PushTokenManagerModule } from './PushTokenManager.types';
export default requireNativeModule<PushTokenManagerModule>('ExpoPushTokenManager');

View File

@@ -0,0 +1,17 @@
import { Platform } from 'expo-modules-core';
import { PushTokenManagerModule } from './PushTokenManager.types';
let warningHasBeenShown = false;
export default {
addListener: () => {
if (!warningHasBeenShown) {
console.warn(
`[expo-notifications] Listening to push token changes is not yet fully supported on ${Platform.OS}. Adding a listener will have no effect.`
);
warningHasBeenShown = true;
}
},
removeListeners: () => {},
} as PushTokenManagerModule;

View File

@@ -0,0 +1,6 @@
import { ProxyNativeModule } from 'expo-modules-core';
export interface PushTokenManagerModule extends ProxyNativeModule {
getDevicePushTokenAsync?: () => Promise<string>;
unregisterForNotificationsAsync?: () => Promise<void>;
}

View File

@@ -0,0 +1,7 @@
import { requireNativeModule } from 'expo-modules-core';
import { ServerRegistrationModule } from './ServerRegistrationModule.types';
export default requireNativeModule<ServerRegistrationModule>(
'NotificationsServerRegistrationModule'
);

View File

@@ -0,0 +1,6 @@
import { ServerRegistrationModule } from './ServerRegistrationModule.types';
export default {
addListener: () => {},
removeListeners: () => {},
} as ServerRegistrationModule;

View File

@@ -0,0 +1,7 @@
import { ProxyNativeModule } from 'expo-modules-core';
export interface ServerRegistrationModule extends ProxyNativeModule {
getInstallationIdAsync?: () => Promise<string>;
getRegistrationInfoAsync?: () => Promise<string | undefined | null>;
setRegistrationInfoAsync?: (registrationInfo: string | null) => Promise<void>;
}

View File

@@ -0,0 +1,56 @@
import { CodedError, uuid } from 'expo-modules-core';
import { ServerRegistrationModule } from './ServerRegistrationModule.types';
const INSTALLATION_ID_KEY = 'EXPO_NOTIFICATIONS_INSTALLATION_ID';
const REGISTRATION_INFO_KEY = 'EXPO_NOTIFICATIONS_REGISTRATION_INFO';
// Lazy fallback installationId per session initializer
let getFallbackInstallationId = () => {
const sessionInstallationId = uuid.v4();
getFallbackInstallationId = () => sessionInstallationId;
};
export default {
getInstallationIdAsync: async () => {
let installationId;
try {
installationId = localStorage.getItem(INSTALLATION_ID_KEY);
if (!installationId || typeof installationId !== 'string') {
installationId = uuid.v4();
localStorage.setItem(INSTALLATION_ID_KEY, installationId);
}
} catch {
installationId = getFallbackInstallationId();
}
return installationId;
},
getRegistrationInfoAsync: async () => {
if (typeof localStorage === 'undefined') {
return null;
}
return localStorage.getItem(REGISTRATION_INFO_KEY);
},
setRegistrationInfoAsync: async (registrationInfo: string | null) => {
if (typeof localStorage === 'undefined') {
return;
}
try {
if (registrationInfo) {
localStorage.setItem(REGISTRATION_INFO_KEY, registrationInfo);
} else {
localStorage.removeItem(REGISTRATION_INFO_KEY);
}
} catch (error) {
throw new CodedError(
'ERR_NOTIFICATIONS_STORAGE_ERROR',
`Could not modify localStorage to persist auto-registration information: ${error}`
);
}
},
// mock implementations
addListener: () => {},
removeListeners: () => {},
} as ServerRegistrationModule;

View File

@@ -0,0 +1,56 @@
import { EventEmitter, Subscription, Platform } from 'expo-modules-core';
import PushTokenManager from './PushTokenManager';
import { DevicePushToken } from './Tokens.types';
/**
* A function accepting a device push token ([`DevicePushToken`](#devicepushtoken)) as an argument.
* > **Note:** You should not call `getDevicePushTokenAsync` inside this function, as it triggers the listener and may lead to an infinite loop.
* @header fetch
*/
export type PushTokenListener = (token: DevicePushToken) => void;
// Web uses SyntheticEventEmitter
const tokenEmitter = new EventEmitter(PushTokenManager);
const newTokenEventName = 'onDevicePushToken';
/**
* In rare situations, a push token may be changed by the push notification service while the app is running.
* When a token is rolled, the old one becomes invalid and sending notifications to it will fail.
* A push token listener will let you handle this situation gracefully by registering the new token with your backend right away.
* @param listener A function accepting a push token as an argument, it will be called whenever the push token changes.
* @return A [`Subscription`](#subscription) object represents the subscription of the provided listener.
* @header fetch
* @example Registering a push token listener using a React hook.
* ```jsx
* import React from 'react';
* import * as Notifications from 'expo-notifications';
*
* import { registerDevicePushTokenAsync } from '../api';
*
* export default function App() {
* React.useEffect(() => {
* const subscription = Notifications.addPushTokenListener(registerDevicePushTokenAsync);
* return () => subscription.remove();
* }, []);
*
* return (
* // Your app content
* );
* }
* ```
*/
export function addPushTokenListener(listener: PushTokenListener): Subscription {
const wrappingListener = ({ devicePushToken }) =>
listener({ data: devicePushToken, type: Platform.OS });
return tokenEmitter.addListener(newTokenEventName, wrappingListener);
}
/**
* Removes a push token subscription returned by an `addPushTokenListener` call.
* @param subscription A subscription returned by `addPushTokenListener` method.
* @header fetch
*/
export function removePushTokenSubscription(subscription: Subscription) {
tokenEmitter.removeSubscription(subscription);
}

View File

@@ -0,0 +1,114 @@
import { type Platform } from 'expo-modules-core';
// @docsMissing
export interface NativeDevicePushToken {
type: 'ios' | 'android';
data: string;
}
// @docsMissing
export interface WebDevicePushToken {
type: 'web';
data: {
endpoint: string;
keys: WebDevicePushTokenKeys;
};
}
// @docsMissing
export type WebDevicePushTokenKeys = {
p256dh: string;
auth: string;
};
// @docsMissing
export type ExplicitlySupportedDevicePushToken = NativeDevicePushToken | WebDevicePushToken;
export type ImplicitlySupportedDevicePushToken = {
/**
* Either `android`, `ios` or `web`.
*/
type: Exclude<typeof Platform.OS, ExplicitlySupportedDevicePushToken['type']>;
/**
* Either the push token as a string (when for native platforms), or an object conforming to the type below (for web):
* ```ts
* {
* endpoint: string;
* keys: {
* p256dh: string;
* auth: string;
* }
* }
* ```
*/
data: any;
};
/**
* In simple terms, an object of `type: Platform.OS` and `data: any`. The `data` type depends on the environment - on a native device it will be a string,
* which you can then use to send notifications via Firebase Cloud Messaging (Android) or APNs (iOS); on web it will be a registration object (VAPID).
*/
export type DevicePushToken =
| ExplicitlySupportedDevicePushToken
| ImplicitlySupportedDevicePushToken;
/**
* Borrowing structure from `DevicePushToken` a little. You can use the `data` value to send notifications via Expo Notifications service.
*/
export interface ExpoPushToken {
/**
* Always set to `"expo"`.
*/
type: 'expo';
/**
* The acquired push token.
*/
data: string;
}
// @needsAudit
export interface ExpoPushTokenOptions {
/**
* Endpoint URL override.
*/
baseUrl?: string;
/**
* Request URL override.
*/
url?: string;
/**
* Request body override.
*/
type?: string;
// @docsMissing
deviceId?: string;
/**
* Makes sense only on iOS, where there are two push notification services: "sandbox" and "production".
* This defines whether the push token is supposed to be used with the sandbox platform notification service.
* Defaults to [`Application.getIosPushNotificationServiceEnvironmentAsync()`](./application/#applicationgetiospushnotificationserviceenvironmentasync)
* exposed by `expo-application` or `false`. Most probably you won't need to customize that.
* You may want to customize that if you don't want to install `expo-application` and still use the sandbox APNs.
* @platform ios
*/
development?: boolean;
/**
* The ID of the project to which the token should be attributed.
* Defaults to [`Constants.expoConfig.extra.eas.projectId`](./constants/#easconfig) exposed by `expo-constants`.
*
* When using EAS Build, this value is automatically set. However, it is
* **recommended** to set it manually. Once you have EAS Build configured, you can find
* the value in **app.json** under `extra.eas.projectId`. You can copy and paste it into your code.
* If you are not using EAS Build, it will fallback to [`Constants.expoConfig?.extra?.eas?.projectId`](./constants/#manifest).
*/
projectId?: string;
/**
* The ID of the application to which the token should be attributed.
* Defaults to [`Application.applicationId`](./application/#applicationapplicationid) exposed by `expo-application`.
*/
applicationId?: string;
/**
* The device push token with which to register at the backend.
* Defaults to a token fetched with [`getDevicePushTokenAsync()`](#getdevicepushtokenasync).
*/
devicePushToken?: DevicePushToken;
}

View File

@@ -0,0 +1,16 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationScheduler from './NotificationScheduler';
/**
* Cancels all scheduled notifications.
* @return A Promise that resolves once all the scheduled notifications are successfully canceled, or if there are no scheduled notifications.
* @header schedule
*/
export default async function cancelAllScheduledNotificationsAsync(): Promise<void> {
if (!NotificationScheduler.cancelAllScheduledNotificationsAsync) {
throw new UnavailabilityError('Notifications', 'cancelAllScheduledNotificationsAsync');
}
return await NotificationScheduler.cancelAllScheduledNotificationsAsync();
}

View File

@@ -0,0 +1,31 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationScheduler from './NotificationScheduler';
/**
* Cancels a single scheduled notification. The scheduled notification of given ID will not trigger.
* @param identifier The notification identifier with which `scheduleNotificationAsync` method resolved when the notification has been scheduled.
* @return A Promise resolves once the scheduled notification is successfully canceled or if there is no scheduled notification for a given identifier.
* @example Schedule and then cancel the notification:
* ```ts
* import * as Notifications from 'expo-notifications';
*
* async function scheduleAndCancel() {
* const identifier = await Notifications.scheduleNotificationAsync({
* content: {
* title: 'Hey!',
* },
* trigger: { seconds: 60, repeats: true },
* });
* await Notifications.cancelScheduledNotificationAsync(identifier);
* }
* ```
* @header schedule
*/
export default async function cancelScheduledNotificationAsync(identifier: string): Promise<void> {
if (!NotificationScheduler.cancelScheduledNotificationAsync) {
throw new UnavailabilityError('Notifications', 'cancelScheduledNotificationAsync');
}
return await NotificationScheduler.cancelScheduledNotificationAsync(identifier);
}

View File

@@ -0,0 +1,22 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationCategoriesModule from './NotificationCategoriesModule';
/**
* Deletes the category associated with the provided identifier.
* @param identifier Identifier initially provided to `setNotificationCategoryAsync` when creating the category.
* @return A Promise which resolves to `true` if the category was successfully deleted, or `false` if it was not.
* An example of when this method would return `false` is if you try to delete a category that doesn't exist.
* @platform android
* @platform ios
* @header categories
*/
export default async function deleteNotificationCategoryAsync(
identifier: string
): Promise<boolean> {
if (!NotificationCategoriesModule.deleteNotificationCategoryAsync) {
throw new UnavailabilityError('Notifications', 'deleteNotificationCategoryAsync');
}
return await NotificationCategoriesModule.deleteNotificationCategoryAsync(identifier);
}

View File

@@ -0,0 +1,11 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationChannelManager from './NotificationChannelManager';
export default async function deleteNotificationChannelAsync(channelId: string): Promise<void> {
if (!NotificationChannelManager.deleteNotificationChannelAsync) {
throw new UnavailabilityError('Notifications', 'deleteNotificationChannelAsync');
}
return await NotificationChannelManager.deleteNotificationChannelAsync(channelId);
}

View File

@@ -0,0 +1,10 @@
/**
* Removes the notification channel.
* @param channelId The channel identifier.
* @return A Promise which resolving once the channel is removed (or if there was no channel for given identifier).
* @platform android
* @header channels
*/
export default async function deleteNotificationChannelAsync(channelId: string): Promise<void> {
console.debug('Notification channels feature is only supported on Android.');
}

View File

@@ -0,0 +1,11 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationChannelGroupManager from './NotificationChannelGroupManager';
export default async function deleteNotificationChannelAsync(groupId: string): Promise<void> {
if (!NotificationChannelGroupManager.deleteNotificationChannelGroupAsync) {
throw new UnavailabilityError('Notifications', 'deleteNotificationChannelGroupAsync');
}
return await NotificationChannelGroupManager.deleteNotificationChannelGroupAsync(groupId);
}

View File

@@ -0,0 +1,10 @@
/**
* Removes the notification channel group and all notification channels that belong to it.
* @param groupId The channel group identifier.
* @return A Promise which resolves once the channel group is removed (or if there was no channel group for given identifier).
* @platform android
* @header channels
*/
export default async function deleteNotificationChannelGroupAsync(groupId: string): Promise<void> {
console.debug('Notification channels feature is only supported on Android.');
}

View File

@@ -0,0 +1,16 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationPresenter from './NotificationPresenterModule';
/**
* Removes all application's notifications displayed in the notification tray (Notification Center).
* @return A Promise which resolves once the request to dismiss the notifications is successfully dispatched to the notifications manager.
* @header dismiss
*/
export default async function dismissAllNotificationsAsync(): Promise<void> {
if (!NotificationPresenter.dismissAllNotificationsAsync) {
throw new UnavailabilityError('Notifications', 'dismissAllNotificationsAsync');
}
return await NotificationPresenter.dismissAllNotificationsAsync();
}

View File

@@ -0,0 +1,19 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationPresenter from './NotificationPresenterModule';
/**
* Removes notification displayed in the notification tray (Notification Center).
* @param notificationIdentifier The notification identifier, obtained either via `setNotificationHandler` method or in the listener added with `addNotificationReceivedListener`.
* @return A Promise which resolves once the request to dismiss the notification is successfully dispatched to the notifications manager.
* @header dismiss
*/
export default async function dismissNotificationAsync(
notificationIdentifier: string
): Promise<void> {
if (!NotificationPresenter.dismissNotificationAsync) {
throw new UnavailabilityError('Notifications', 'dismissNotificationAsync');
}
return await NotificationPresenter.dismissNotificationAsync(notificationIdentifier);
}

View File

@@ -0,0 +1,20 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationScheduler from './NotificationScheduler';
import { NotificationRequest } from './Notifications.types';
import { mapNotificationRequest } from './utils/mapNotificationResponse';
/**
* Fetches information about all scheduled notifications.
* @return Returns a Promise resolving to an array of objects conforming to the [`Notification`](#notification) interface.
* @header schedule
*/
export default async function getAllScheduledNotificationsAsync(): Promise<NotificationRequest[]> {
if (!NotificationScheduler.getAllScheduledNotificationsAsync) {
throw new UnavailabilityError('Notifications', 'getAllScheduledNotificationsAsync');
}
return (await NotificationScheduler.getAllScheduledNotificationsAsync()).map((request) =>
mapNotificationRequest(request)
);
}

View File

@@ -0,0 +1,17 @@
import { UnavailabilityError } from 'expo-modules-core';
import BadgeModule from './BadgeModule';
/**
* Fetches the number currently set as the badge of the app icon on device's home screen. A `0` value means that the badge is not displayed.
* > **Note:** Not all Android launchers support application badges. If the launcher does not support icon badges, the method will always resolve to `0`.
* @return Returns a Promise resolving to a number that represents the current badge of the app icon.
* @header badge
*/
export default async function getBadgeCountAsync(): Promise<number> {
if (!BadgeModule.getBadgeCountAsync) {
throw new UnavailabilityError('ExpoNotifications', 'getBadgeCountAsync');
}
return await BadgeModule.getBadgeCountAsync();
}

View File

@@ -0,0 +1,31 @@
import { UnavailabilityError, Platform } from 'expo-modules-core';
import PushTokenManager from './PushTokenManager';
import { DevicePushToken } from './Tokens.types';
let nativeTokenPromise: Promise<string> | null = null;
/**
* Returns a native FCM, APNs token or a [`PushSubscription` data](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription)
* that can be used with another push notification service.
* @header fetch
*/
export default async function getDevicePushTokenAsync(): Promise<DevicePushToken> {
if (!PushTokenManager.getDevicePushTokenAsync) {
throw new UnavailabilityError('ExpoNotifications', 'getDevicePushTokenAsync');
}
let devicePushToken: string | null;
if (nativeTokenPromise) {
// Reuse existing Promise
devicePushToken = await nativeTokenPromise;
} else {
// Create a new Promise and clear it afterwards
nativeTokenPromise = PushTokenManager.getDevicePushTokenAsync();
devicePushToken = await nativeTokenPromise;
nativeTokenPromise = null;
}
// @ts-ignore: TS thinks Platform.OS could be anything and can't decide what type is it
return { type: Platform.OS, data: devicePushToken };
}

View File

@@ -0,0 +1,121 @@
import Constants from 'expo-constants';
import { CodedError, DeviceEventEmitter, Platform } from 'expo-modules-core';
import { DevicePushToken } from './Tokens.types';
export default async function getDevicePushTokenAsync(): Promise<DevicePushToken> {
const data = await _subscribeDeviceToPushNotificationsAsync();
DeviceEventEmitter.emit('onDevicePushToken', { devicePushToken: data });
return { type: Platform.OS, data };
}
function guardPermission() {
if (!('Notification' in window)) {
throw new CodedError(
'ERR_UNAVAILABLE',
'The Web Notifications API is not available on this device.'
);
}
if (!navigator.serviceWorker) {
throw new CodedError(
'ERR_UNAVAILABLE',
'Notifications cannot be used because the service worker API is not supported on this device. This might also happen because your web page does not support HTTPS.'
);
}
if (Notification.permission !== 'granted') {
throw new CodedError(
'ERR_NOTIFICATIONS_PERMISSION_DENIED',
`Cannot use web notifications without permissions granted. Request permissions with "expo-permissions".`
);
}
}
async function _subscribeDeviceToPushNotificationsAsync(): Promise<DevicePushToken['data']> {
// @ts-expect-error: TODO: not on the schema
const vapidPublicKey: string | null = Constants.expoConfig?.notification?.vapidPublicKey;
if (!vapidPublicKey) {
throw new CodedError(
'ERR_NOTIFICATIONS_PUSH_WEB_MISSING_CONFIG',
'You must provide `notification.vapidPublicKey` in `app.json` to use push notifications on web. Learn more: https://docs.expo.dev/versions/latest/guides/using-vapid/.'
);
}
// @ts-expect-error: TODO: not on the schema
const serviceWorkerPath = Constants.expoConfig?.notification?.serviceWorkerPath;
if (!serviceWorkerPath) {
throw new CodedError(
'ERR_NOTIFICATIONS_PUSH_MISSING_CONFIGURATION',
'You must specify `notification.serviceWorkerPath` in `app.json` to use push notifications on the web. Please provide the path to the service worker that will handle notifications.'
);
}
guardPermission();
let registration: ServiceWorkerRegistration | null = null;
try {
registration = await navigator.serviceWorker.register(serviceWorkerPath);
} catch (error) {
throw new CodedError(
'ERR_NOTIFICATIONS_PUSH_REGISTRATION_FAILED',
`Could not register this device for push notifications because the service worker (${serviceWorkerPath}) could not be registered: ${error}`
);
}
await navigator.serviceWorker.ready;
if (!registration.active) {
throw new CodedError(
'ERR_NOTIFICATIONS_PUSH_REGISTRATION_FAILED',
'Could not register this device for push notifications because the service worker is not active.'
);
}
const subscribeOptions = {
userVisibleOnly: true,
applicationServerKey: _urlBase64ToUint8Array(vapidPublicKey),
};
let pushSubscription: PushSubscription | null = null;
try {
pushSubscription = await registration.pushManager.subscribe(subscribeOptions);
} catch (error) {
throw new CodedError(
'ERR_NOTIFICATIONS_PUSH_REGISTRATION_FAILED',
'The device was unable to register for remote notifications with the browser endpoint. (' +
error +
')'
);
}
const pushSubscriptionJson = pushSubscription.toJSON();
const subscriptionObject = {
endpoint: pushSubscriptionJson.endpoint,
keys: {
p256dh: pushSubscriptionJson.keys!.p256dh,
auth: pushSubscriptionJson.keys!.auth,
},
};
// Store notification icon string in service worker.
// This message is received by `/expo-service-worker.js`.
// We wrap it with `fromExpoWebClient` to make sure other message
// will not override content such as `notificationIcon`.
// https://stackoverflow.com/a/35729334/2603230
const notificationIcon = (Constants.expoConfig?.notification ?? {}).icon;
await registration.active.postMessage(
JSON.stringify({ fromExpoWebClient: { notificationIcon } })
);
return subscriptionObject;
}
// https://github.com/web-push-libs/web-push#using-vapid-key-for-applicationserverkey
function _urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

View File

@@ -0,0 +1,238 @@
import * as Application from 'expo-application';
import Constants from 'expo-constants';
import { Platform, CodedError, UnavailabilityError } from 'expo-modules-core';
import { setAutoServerRegistrationEnabledAsync } from './DevicePushTokenAutoRegistration.fx';
import ServerRegistrationModule from './ServerRegistrationModule';
import { DevicePushToken, ExpoPushToken, ExpoPushTokenOptions } from './Tokens.types';
import getDevicePushTokenAsync from './getDevicePushTokenAsync';
const productionBaseUrl = 'https://exp.host/--/api/v2/';
/**
* Returns an Expo token that can be used to send a push notification to the device using Expo's push notifications service.
*
* This method makes requests to the Expo's servers. It can get rejected in cases where the request itself fails
* (for example, due to the device being offline, experiencing a network timeout, or other HTTPS request failures).
* To provide offline support to your users, you should `try/catch` this method and implement retry logic to attempt
* to get the push token later, once the device is back online.
*
* > For Expo's backend to be able to send notifications to your app, you will need to provide it with push notification keys.
* For more information, see [credentials](/push-notifications/push-notifications-setup/#get-credentials-for-development-builds) in the push notifications setup.
*
* @param options Object allowing you to pass in push notification configuration.
* @return Returns a `Promise` that resolves to an object representing acquired push token.
* @header fetch
*
* @example
* ```ts
* import * as Notifications from 'expo-notifications';
*
* export async function registerForPushNotificationsAsync(userId: string) {
* const expoPushToken = await Notifications.getExpoPushTokenAsync({
* projectId: 'your-project-id',
* });
*
* await fetch('https://example.com/', {
* method: 'POST',
* headers: {
* 'Content-Type': 'application/json',
* },
* body: JSON.stringify({
* userId,
* expoPushToken,
* }),
* });
* }
* ```
*/
export default async function getExpoPushTokenAsync(
options: ExpoPushTokenOptions = {}
): Promise<ExpoPushToken> {
const devicePushToken = options.devicePushToken || (await getDevicePushTokenAsync());
const deviceId = options.deviceId || (await getDeviceIdAsync());
const projectId = options.projectId || Constants.easConfig?.projectId;
if (!projectId) {
console.warn(
'Calling getExpoPushTokenAsync without specifying a projectId is deprecated and will no longer be supported in SDK 49+'
);
}
if (!projectId) {
throw new CodedError(
'ERR_NOTIFICATIONS_NO_EXPERIENCE_ID',
"No 'projectId' found. If 'projectId' can't be inferred from the manifest (eg. in bare workflow), you have to pass it in yourself."
);
}
const applicationId = options.applicationId || Application.applicationId;
if (!applicationId) {
throw new CodedError(
'ERR_NOTIFICATIONS_NO_APPLICATION_ID',
"No applicationId found. If it can't be inferred from native configuration by expo-application, you have to pass it in yourself."
);
}
const type = options.type || getTypeOfToken(devicePushToken);
const development = options.development || (await shouldUseDevelopmentNotificationService());
const baseUrl = options.baseUrl ?? productionBaseUrl;
const url = options.url ?? `${baseUrl}push/getExpoPushToken`;
const body = {
type,
deviceId: deviceId.toLowerCase(),
development,
appId: applicationId,
deviceToken: getDeviceToken(devicePushToken),
projectId,
};
const response = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(body),
}).catch((error) => {
throw new CodedError(
'ERR_NOTIFICATIONS_NETWORK_ERROR',
`Error encountered while fetching Expo token: ${error}.`
);
});
if (!response.ok) {
const statusInfo = response.statusText || response.status;
let body: string | undefined = undefined;
try {
body = await response.text();
} catch {
// do nothing
}
throw new CodedError(
'ERR_NOTIFICATIONS_SERVER_ERROR',
`Error encountered while fetching Expo token, expected an OK response, received: ${statusInfo} (body: "${body}").`
);
}
const expoPushToken = getExpoPushToken(await parseResponse(response));
try {
if (options.url || options.baseUrl) {
console.debug(
`[expo-notifications] Since the URL endpoint to register in has been customized in the options, expo-notifications won't try to auto-update the device push token on the server.`
);
} else {
await setAutoServerRegistrationEnabledAsync(true);
}
} catch (e) {
console.warn(
'[expo-notifications] Could not enable automatically registering new device tokens with the Expo notification service',
e
);
}
return {
type: 'expo',
data: expoPushToken,
};
}
async function parseResponse(response: Response) {
try {
return await response.json();
} catch {
try {
throw new CodedError(
'ERR_NOTIFICATIONS_SERVER_ERROR',
`Expected a JSON response from server when fetching Expo token, received body: ${JSON.stringify(
await response.text()
)}.`
);
} catch {
throw new CodedError(
'ERR_NOTIFICATIONS_SERVER_ERROR',
`Expected a JSON response from server when fetching Expo token, received response: ${JSON.stringify(
response
)}.`
);
}
}
}
function getExpoPushToken(data: any) {
if (
!data ||
!(typeof data === 'object') ||
!data.data ||
!(typeof data.data === 'object') ||
!data.data.expoPushToken ||
!(typeof data.data.expoPushToken === 'string')
) {
throw new CodedError(
'ERR_NOTIFICATIONS_SERVER_ERROR',
`Malformed response from server, expected "{ data: { expoPushToken: string } }", received: ${JSON.stringify(
data,
null,
2
)}.`
);
}
return data.data.expoPushToken as string;
}
// Same as in DevicePushTokenAutoRegistration
async function getDeviceIdAsync() {
try {
if (!ServerRegistrationModule.getInstallationIdAsync) {
throw new UnavailabilityError('ExpoServerRegistrationModule', 'getInstallationIdAsync');
}
return await ServerRegistrationModule.getInstallationIdAsync();
} catch (e) {
throw new CodedError(
'ERR_NOTIF_DEVICE_ID',
`Could not have fetched installation ID of the application: ${e}.`
);
}
}
function getDeviceToken(devicePushToken: DevicePushToken) {
if (typeof devicePushToken.data === 'string') {
return devicePushToken.data;
}
return JSON.stringify(devicePushToken.data);
}
// Same as in DevicePushTokenAutoRegistration
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;
}
// Same as in DevicePushTokenAutoRegistration
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;
}
}

View File

@@ -0,0 +1,38 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationScheduler from './NotificationScheduler';
import { SchedulableNotificationTriggerInput } from './Notifications.types';
import { parseTrigger } from './scheduleNotificationAsync';
/**
* Allows you to check what will be the next trigger date for given notification trigger input.
* @param trigger The schedulable notification trigger you would like to check next trigger date for (of type [`SchedulableNotificationTriggerInput`](#schedulablenotificationtriggerinput)).
* @return If the return value is `null`, the notification won't be triggered. Otherwise, the return value is the Unix timestamp in milliseconds
* at which the notification will be triggered.
* @example Calculate next trigger date for a notification trigger:
* ```ts
* import * as Notifications from 'expo-notifications';
*
* async function logNextTriggerDate() {
* try {
* const nextTriggerDate = await Notifications.getNextTriggerDateAsync({
* hour: 9,
* minute: 0,
* });
* console.log(nextTriggerDate === null ? 'No next trigger date' : new Date(nextTriggerDate));
* } catch (e) {
* console.warn(`Couldn't have calculated next trigger date: ${e}`);
* }
* }
* ```
* @header schedule
*/
export default async function getNextTriggerDateAsync(
trigger: SchedulableNotificationTriggerInput
): Promise<number | null> {
if (!NotificationScheduler.getNextTriggerDateAsync) {
throw new UnavailabilityError('ExpoNotifications', 'getNextTriggerDateAsync');
}
return await NotificationScheduler.getNextTriggerDateAsync(parseTrigger(trigger));
}

View File

@@ -0,0 +1,20 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationCategoriesModule from './NotificationCategoriesModule';
import { NotificationCategory } from './Notifications.types';
/**
* Fetches information about all known notification categories.
* @return A Promise which resolves to an array of `NotificationCategory`s. On platforms that do not support notification channels,
* it will always resolve to an empty array.
* @platform android
* @platform ios
* @header categories
*/
export default async function getNotificationCategoriesAsync(): Promise<NotificationCategory[]> {
if (!NotificationCategoriesModule.getNotificationCategoriesAsync) {
throw new UnavailabilityError('Notifications', 'getNotificationCategoriesAsync');
}
return await NotificationCategoriesModule.getNotificationCategoriesAsync();
}

View File

@@ -0,0 +1,13 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationChannelManager from './NotificationChannelManager';
import { NotificationChannel } from './NotificationChannelManager.types';
export default async function getNotificationChannelAsync(
channelId: string
): Promise<NotificationChannel | null> {
if (!NotificationChannelManager.getNotificationChannelAsync) {
throw new UnavailabilityError('Notifications', 'getNotificationChannelAsync');
}
return await NotificationChannelManager.getNotificationChannelAsync(channelId);
}

View File

@@ -0,0 +1,16 @@
import { NotificationChannel } from './NotificationChannelManager.types';
/**
* Fetches information about a single notification channel.
* @param channelId The channel's identifier.
* @return A Promise which resolves to the channel object (of type [`NotificationChannel`](#notificationchannel)) or to `null`
* if there was no channel found for this identifier. On platforms that do not support notification channels, it will always resolve to `null`.
* @platform android
* @header channels
*/
export default async function getNotificationChannelAsync(
channelId: string
): Promise<NotificationChannel | null> {
console.debug('Notification channels feature is only supported on Android.');
return null;
}

View File

@@ -0,0 +1,14 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationChannelGroupManager from './NotificationChannelGroupManager';
import { NotificationChannelGroup } from './NotificationChannelGroupManager.types';
export default async function getNotificationChannelGroupAsync(
groupId: string
): Promise<NotificationChannelGroup | null> {
if (!NotificationChannelGroupManager.getNotificationChannelGroupAsync) {
throw new UnavailabilityError('Notifications', 'getNotificationChannelGroupAsync');
}
return await NotificationChannelGroupManager.getNotificationChannelGroupAsync(groupId);
}

View File

@@ -0,0 +1,17 @@
import { NotificationChannelGroup } from './NotificationChannelGroupManager.types';
/**
* Fetches information about a single notification channel group.
* @param groupId The channel group's identifier.
* @return A Promise which resolves to the channel group object (of type [`NotificationChannelGroup`](#notificationchannelgroup))
* or to `null` if there was no channel group found for this identifier. On platforms that do not support notification channels,
* it will always resolve to `null`.
* @platform android
* @header channels
*/
export default async function getNotificationChannelGroupAsync(
groupId: string
): Promise<NotificationChannelGroup | null> {
console.debug('Notification channels feature is only supported on Android.');
return null;
}

View File

@@ -0,0 +1,13 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationChannelGroupManager from './NotificationChannelGroupManager';
import { NotificationChannelGroup } from './NotificationChannelGroupManager.types';
export default async function getNotificationChannelGroupsAsync(): Promise<
NotificationChannelGroup[]
> {
if (!NotificationChannelGroupManager.getNotificationChannelGroupsAsync) {
throw new UnavailabilityError('Notifications', 'getNotificationChannelGroupsAsync');
}
return await NotificationChannelGroupManager.getNotificationChannelGroupsAsync();
}

View File

@@ -0,0 +1,15 @@
import { NotificationChannelGroup } from './NotificationChannelGroupManager.types';
/**
* Fetches information about all known notification channel groups.
* @return A Promise which resoles to an array of channel groups. On platforms that do not support notification channel groups,
* it will always resolve to an empty array.
* @platform android
* @header channels
*/
export default async function getNotificationChannelGroupsAsync(): Promise<
NotificationChannelGroup[]
> {
console.debug('Notification channels feature is only supported on Android.');
return [];
}

View File

@@ -0,0 +1,11 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationChannelManager from './NotificationChannelManager';
import { NotificationChannel } from './NotificationChannelManager.types';
export default async function getNotificationChannelsAsync(): Promise<NotificationChannel[]> {
if (!NotificationChannelManager.getNotificationChannelsAsync) {
throw new UnavailabilityError('Notifications', 'getNotificationChannelsAsync');
}
return (await NotificationChannelManager.getNotificationChannelsAsync()) ?? [];
}

View File

@@ -0,0 +1,13 @@
import { NotificationChannel } from './NotificationChannelManager.types';
/**
* Fetches information about all known notification channels.
* @return A Promise which resolves to an array of channels. On platforms that do not support notification channels,
* it will always resolve to an empty array.
* @platform android
* @header channels
*/
export default async function getNotificationChannelsAsync(): Promise<NotificationChannel[]> {
console.debug('Notification channels feature is only supported on Android.');
return [];
}

View File

@@ -0,0 +1,21 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationPresenter from './NotificationPresenterModule';
import { Notification } from './Notifications.types';
import { mapNotification } from './utils/mapNotificationResponse';
/**
* Fetches information about all notifications present in the notification tray (Notification Center).
* > This method is not supported on Android below 6.0 (API level 23) on these devices it will resolve to an empty array.
* @return A Promise which resolves with a list of notifications ([`Notification`](#notification)) currently present in the notification tray (Notification Center).
* @header dismiss
*/
export default async function getPresentedNotificationsAsync(): Promise<Notification[]> {
if (!NotificationPresenter.getPresentedNotificationsAsync) {
throw new UnavailabilityError('Notifications', 'getPresentedNotificationsAsync');
}
return (await NotificationPresenter.getPresentedNotificationsAsync()).map((notification) =>
mapNotification(notification)
);
}

View File

@@ -0,0 +1,38 @@
export { default as getDevicePushTokenAsync } from './getDevicePushTokenAsync';
export { default as unregisterForNotificationsAsync } from './unregisterForNotificationsAsync';
export { default as getExpoPushTokenAsync } from './getExpoPushTokenAsync';
export { default as getPresentedNotificationsAsync } from './getPresentedNotificationsAsync';
export { default as presentNotificationAsync } from './presentNotificationAsync';
export { default as dismissNotificationAsync } from './dismissNotificationAsync';
export { default as dismissAllNotificationsAsync } from './dismissAllNotificationsAsync';
export { default as getNotificationChannelsAsync } from './getNotificationChannelsAsync';
export { default as getNotificationChannelAsync } from './getNotificationChannelAsync';
export { default as setNotificationChannelAsync } from './setNotificationChannelAsync';
export { default as deleteNotificationChannelAsync } from './deleteNotificationChannelAsync';
export { default as getNotificationChannelGroupsAsync } from './getNotificationChannelGroupsAsync';
export { default as getNotificationChannelGroupAsync } from './getNotificationChannelGroupAsync';
export { default as setNotificationChannelGroupAsync } from './setNotificationChannelGroupAsync';
export { default as deleteNotificationChannelGroupAsync } from './deleteNotificationChannelGroupAsync';
export { default as getBadgeCountAsync } from './getBadgeCountAsync';
export { default as setBadgeCountAsync } from './setBadgeCountAsync';
export { default as getAllScheduledNotificationsAsync } from './getAllScheduledNotificationsAsync';
export { default as scheduleNotificationAsync } from './scheduleNotificationAsync';
export { default as cancelScheduledNotificationAsync } from './cancelScheduledNotificationAsync';
export { default as cancelAllScheduledNotificationsAsync } from './cancelAllScheduledNotificationsAsync';
export { default as getNotificationCategoriesAsync } from './getNotificationCategoriesAsync';
export { default as setNotificationCategoryAsync } from './setNotificationCategoryAsync';
export { default as deleteNotificationCategoryAsync } from './deleteNotificationCategoryAsync';
export { default as getNextTriggerDateAsync } from './getNextTriggerDateAsync';
export { default as useLastNotificationResponse } from './useLastNotificationResponse';
export { setAutoServerRegistrationEnabledAsync } from './DevicePushTokenAutoRegistration.fx';
export { default as registerTaskAsync } from './registerTaskAsync';
export { default as unregisterTaskAsync } from './unregisterTaskAsync';
export * from './TokenEmitter';
export * from './NotificationsEmitter';
export * from './NotificationsHandler';
export * from './NotificationPermissions';
export * from './NotificationChannelGroupManager.types';
export * from './NotificationChannelManager.types';
export * from './NotificationPermissions.types';
export * from './Notifications.types';
export * from './Tokens.types';

View File

@@ -0,0 +1,32 @@
import { UnavailabilityError, uuid } from 'expo-modules-core';
import NotificationPresenter from './NotificationPresenterModule';
import { NotificationContentInput } from './Notifications.types';
let warningMessageShown = false;
/**
* Schedules a notification for immediate trigger.
* @param content An object representing the notification content.
* @param identifier
* @return It returns a Promise resolving with the notification's identifier once the notification is successfully scheduled for immediate display.
* @header schedule
* @deprecated This method has been deprecated in favor of using an explicit `NotificationHandler` and the [`scheduleNotificationAsync`](#notificationsschedulenotificationasyncrequest) method. More information can be found in our [FYI document](https://expo.fyi/presenting-notifications-deprecated).
*/
export default async function presentNotificationAsync(
content: NotificationContentInput,
identifier: string = uuid.v4()
): Promise<string> {
if (__DEV__ && !warningMessageShown) {
console.warn(
'`presentNotificationAsync` has been deprecated in favor of using `scheduleNotificationAsync` + an explicit notification handler. Read more at https://expo.fyi/presenting-notifications-deprecated.'
);
warningMessageShown = true;
}
if (!NotificationPresenter.presentNotificationAsync) {
throw new UnavailabilityError('Notifications', 'presentNotificationAsync');
}
return await NotificationPresenter.presentNotificationAsync(identifier, content);
}

View File

@@ -0,0 +1,38 @@
import { UnavailabilityError } from 'expo-modules-core';
import BackgroundNotificationTasksModule from './BackgroundNotificationTasksModule';
/**
* When a notification is received while the app is backgrounded, using this function you can set a callback that will be run in response to that notification.
* Under the hood, this function is run using `expo-task-manager`. You **must** define the task first, with [`TaskManager.defineTask`](./task-manager#taskmanagerdefinetasktaskname-taskexecutor).
* Make sure you define it in the global scope.
*
* The callback function you define with `TaskManager.defineTask` will receive an object with the following fields:
* - `data`: The remote payload delivered by either FCM (Android) or APNs (iOS). See [`PushNotificationTrigger`](#pushnotificationtrigger) for details.
* - `error`: The error (if any) that occurred during execution of the task.
* - `executionInfo`: JSON object of additional info related to the task, including the `taskName`.
* @param taskName The string you passed to `TaskManager.defineTask` as the `taskName` parameter.
*
* @example
* ```ts
* import * as TaskManager from 'expo-task-manager';
* import * as Notifications from 'expo-notifications';
*
* const BACKGROUND_NOTIFICATION_TASK = 'BACKGROUND-NOTIFICATION-TASK';
*
* TaskManager.defineTask(BACKGROUND_NOTIFICATION_TASK, ({ data, error, executionInfo }) => {
* console.log('Received a notification in the background!');
* // Do something with the notification data
* });
*
* Notifications.registerTaskAsync(BACKGROUND_NOTIFICATION_TASK);
* ```
* @header inBackground
*/
export default async function registerTaskAsync(taskName: string): Promise<null> {
if (!BackgroundNotificationTasksModule.registerTaskAsync) {
throw new UnavailabilityError('Notifications', 'registerTaskAsync');
}
return await BackgroundNotificationTasksModule.registerTaskAsync(taskName);
}

View File

@@ -0,0 +1,334 @@
import { Platform, UnavailabilityError, uuid } from 'expo-modules-core';
import NotificationScheduler from './NotificationScheduler';
import { NotificationTriggerInput as NativeNotificationTriggerInput } from './NotificationScheduler.types';
import {
NotificationRequestInput,
NotificationTriggerInput,
DailyTriggerInput,
WeeklyTriggerInput,
YearlyTriggerInput,
CalendarTriggerInput,
TimeIntervalTriggerInput,
DateTriggerInput,
ChannelAwareTriggerInput,
SchedulableNotificationTriggerInput,
} from './Notifications.types';
/**
* Schedules a notification to be triggered in the future.
* > **Note:** Please note that this does not mean that the notification will be presented when it is triggered.
* For the notification to be presented you have to set a notification handler with [`setNotificationHandler`](#notificationssetnotificationhandlerhandler)
* that will return an appropriate notification behavior. For more information see the example below.
* @param request An object describing the notification to be triggered.
* @return Returns a Promise resolving to a string which is a notification identifier you can later use to cancel the notification or to identify an incoming notification.
* @example
* # Schedule the notification that will trigger once, in one minute from now
* ```ts
* import * as Notifications from 'expo-notifications';
*
* Notifications.scheduleNotificationAsync({
* content: {
* title: "Time's up!",
* body: 'Change sides!',
* },
* trigger: {
* seconds: 60,
* },
* });
* ```
*
* # Schedule the notification that will trigger repeatedly, every 20 minutes
* ```ts
* import * as Notifications from 'expo-notifications';
*
* Notifications.scheduleNotificationAsync({
* content: {
* title: 'Remember to drink water!',
* },
* trigger: {
* seconds: 60 * 20,
* repeats: true,
* },
* });
* ```
*
* # Schedule the notification that will trigger once, at the beginning of next hour
* ```ts
* import * as Notifications from 'expo-notifications';
*
* const trigger = new Date(Date.now() + 60 * 60 * 1000);
* trigger.setMinutes(0);
* trigger.setSeconds(0);
*
* Notifications.scheduleNotificationAsync({
* content: {
* title: 'Happy new hour!',
* },
* trigger,
* });
* ```
* @header schedule
*/
export default async function scheduleNotificationAsync(
request: NotificationRequestInput
): Promise<string> {
if (!NotificationScheduler.scheduleNotificationAsync) {
throw new UnavailabilityError('Notifications', 'scheduleNotificationAsync');
}
return await NotificationScheduler.scheduleNotificationAsync(
request.identifier ?? uuid.v4(),
request.content,
parseTrigger(request.trigger)
);
}
type ValidTriggerDateComponents = 'month' | 'day' | 'weekday' | 'hour' | 'minute';
const DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS: readonly ValidTriggerDateComponents[] = [
'hour',
'minute',
];
const WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS: readonly ValidTriggerDateComponents[] = [
'weekday',
'hour',
'minute',
];
const YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS: readonly ValidTriggerDateComponents[] = [
'day',
'month',
'hour',
'minute',
];
export function parseTrigger(
userFacingTrigger: NotificationTriggerInput
): NativeNotificationTriggerInput {
if (userFacingTrigger === null) {
return null;
}
if (userFacingTrigger === undefined) {
throw new TypeError(
'Encountered an `undefined` notification trigger. If you want to trigger the notification immediately, pass in an explicit `null` value.'
);
}
if (isDateTrigger(userFacingTrigger)) {
return parseDateTrigger(userFacingTrigger);
} else if (isDailyTriggerInput(userFacingTrigger)) {
validateDateComponentsInTrigger(userFacingTrigger, DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS);
return {
type: 'daily',
channelId: userFacingTrigger.channelId,
hour: userFacingTrigger.hour,
minute: userFacingTrigger.minute,
};
} else if (isWeeklyTriggerInput(userFacingTrigger)) {
validateDateComponentsInTrigger(userFacingTrigger, WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS);
return {
type: 'weekly',
channelId: userFacingTrigger.channelId,
weekday: userFacingTrigger.weekday,
hour: userFacingTrigger.hour,
minute: userFacingTrigger.minute,
};
} else if (isYearlyTriggerInput(userFacingTrigger)) {
validateDateComponentsInTrigger(userFacingTrigger, YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS);
return {
type: 'yearly',
channelId: userFacingTrigger.channelId,
day: userFacingTrigger.day,
month: userFacingTrigger.month,
hour: userFacingTrigger.hour,
minute: userFacingTrigger.minute,
};
} else if (isSecondsPropertyMisusedInCalendarTriggerInput(userFacingTrigger)) {
throw new TypeError(
'Could not have inferred the notification trigger type: if you want to use a time interval trigger, pass in only `seconds` with or without `repeats` property; if you want to use calendar-based trigger, pass in `second`.'
);
} else if ('seconds' in userFacingTrigger) {
return {
type: 'timeInterval',
channelId: userFacingTrigger.channelId,
seconds: userFacingTrigger.seconds,
repeats: userFacingTrigger.repeats ?? false,
};
} else if (isCalendarTrigger(userFacingTrigger)) {
const { repeats, ...calendarTrigger } = userFacingTrigger;
return { type: 'calendar', value: calendarTrigger, repeats };
} else {
return Platform.select({
default: null, // There's no notion of channels on platforms other than Android.
android: { type: 'channel', channelId: userFacingTrigger.channelId },
});
}
}
function isCalendarTrigger(
trigger: CalendarTriggerInput | ChannelAwareTriggerInput
): trigger is CalendarTriggerInput {
const { channelId, ...triggerWithoutChannelId } = trigger;
return Object.keys(triggerWithoutChannelId).length > 0;
}
function isDateTrigger(
trigger:
| DateTriggerInput
| WeeklyTriggerInput
| DailyTriggerInput
| CalendarTriggerInput
| TimeIntervalTriggerInput
): trigger is DateTriggerInput {
return (
trigger instanceof Date ||
typeof trigger === 'number' ||
(typeof trigger === 'object' && 'date' in trigger)
);
}
function parseDateTrigger(trigger: DateTriggerInput): NativeNotificationTriggerInput {
if (trigger instanceof Date || typeof trigger === 'number') {
return { type: 'date', timestamp: toTimestamp(trigger) };
}
return { type: 'date', timestamp: toTimestamp(trigger.date), channelId: trigger.channelId };
}
function toTimestamp(date: number | Date) {
if (date instanceof Date) {
return date.getTime();
}
return date;
}
function isDailyTriggerInput(
trigger: SchedulableNotificationTriggerInput
): trigger is DailyTriggerInput {
if (typeof trigger !== 'object') return false;
const { channelId, ...triggerWithoutChannelId } = trigger as DailyTriggerInput;
return (
Object.keys(triggerWithoutChannelId).length ===
DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS.length + 1 &&
DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS.every(
(component) => component in triggerWithoutChannelId
) &&
'repeats' in triggerWithoutChannelId &&
triggerWithoutChannelId.repeats === true
);
}
function isWeeklyTriggerInput(
trigger: SchedulableNotificationTriggerInput
): trigger is WeeklyTriggerInput {
if (typeof trigger !== 'object') return false;
const { channelId, ...triggerWithoutChannelId } = trigger as WeeklyTriggerInput;
return (
Object.keys(triggerWithoutChannelId).length ===
WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS.length + 1 &&
WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS.every(
(component) => component in triggerWithoutChannelId
) &&
'repeats' in triggerWithoutChannelId &&
triggerWithoutChannelId.repeats === true
);
}
function isYearlyTriggerInput(
trigger: SchedulableNotificationTriggerInput
): trigger is YearlyTriggerInput {
if (typeof trigger !== 'object') return false;
const { channelId, ...triggerWithoutChannelId } = trigger as YearlyTriggerInput;
return (
Object.keys(triggerWithoutChannelId).length ===
YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS.length + 1 &&
YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS.every(
(component) => component in triggerWithoutChannelId
) &&
'repeats' in triggerWithoutChannelId &&
triggerWithoutChannelId.repeats === true
);
}
function isSecondsPropertyMisusedInCalendarTriggerInput(
trigger: TimeIntervalTriggerInput | CalendarTriggerInput
) {
const { channelId, ...triggerWithoutChannelId } = trigger;
return (
// eg. { seconds: ..., repeats: ..., hour: ... }
('seconds' in triggerWithoutChannelId &&
'repeats' in triggerWithoutChannelId &&
Object.keys(triggerWithoutChannelId).length > 2) ||
// eg. { seconds: ..., hour: ... }
('seconds' in triggerWithoutChannelId &&
!('repeats' in triggerWithoutChannelId) &&
Object.keys(triggerWithoutChannelId).length > 1)
);
}
function validateDateComponentsInTrigger(
trigger: NonNullable<NotificationTriggerInput>,
components: readonly ValidTriggerDateComponents[]
) {
const anyTriggerType = trigger as any;
components.forEach((component) => {
if (!(component in anyTriggerType)) {
throw new TypeError(`The ${component} parameter needs to be present`);
}
if (typeof anyTriggerType[component] !== 'number') {
throw new TypeError(`The ${component} parameter should be a number`);
}
switch (component) {
case 'month': {
const { month } = anyTriggerType;
if (month < 0 || month > 11) {
throw new RangeError(`The month parameter needs to be between 0 and 11. Found: ${month}`);
}
break;
}
case 'day': {
const { day, month } = anyTriggerType;
const daysInGivenMonth = daysInMonth(month);
if (day < 1 || day > daysInGivenMonth) {
throw new RangeError(
`The day parameter for month ${month} must be between 1 and ${daysInGivenMonth}. Found: ${day}`
);
}
break;
}
case 'weekday': {
const { weekday } = anyTriggerType;
if (weekday < 1 || weekday > 7) {
throw new RangeError(
`The weekday parameter needs to be between 1 and 7. Found: ${weekday}`
);
}
break;
}
case 'hour': {
const { hour } = anyTriggerType;
if (hour < 0 || hour > 23) {
throw new RangeError(`The hour parameter needs to be between 0 and 23. Found: ${hour}`);
}
break;
}
case 'minute': {
const { minute } = anyTriggerType;
if (minute < 0 || minute > 59) {
throw new RangeError(
`The minute parameter needs to be between 0 and 59. Found: ${minute}`
);
}
break;
}
}
});
}
/**
* Determines the number of days in the given month (or January if omitted).
* If year is specified, it will include leap year logic, else it will always assume a leap year
*/
function daysInMonth(month: number = 0, year?: number) {
return new Date(year ?? 2000, month + 1, 0).getDate();
}

View File

@@ -0,0 +1,32 @@
import { UnavailabilityError, Platform } from 'expo-modules-core';
import BadgeModule from './BadgeModule';
import { WebSetBadgeCountOptions } from './BadgeModule.types';
export interface SetBadgeCountOptions {
/**
* A configuration object described [in the `badgin` documentation](https://github.com/jaulz/badgin#options).
*/
web?: WebSetBadgeCountOptions;
}
/**
* Sets the badge of the app's icon to the specified number. Setting it to `0` clears the badge. On iOS, this method requires that you have requested
* the user's permission for `allowBadge` via [`requestPermissionsAsync`](#notificationsrequestpermissionsasyncpermissions),
* otherwise it will automatically return `false`.
* > **Note:** Not all Android launchers support application badges. If the launcher does not support icon badges, the method will resolve to `false`.
* @param badgeCount The count which should appear on the badge. A value of `0` will clear the badge.
* @param options An object of options configuring behavior applied in Web environment.
* @return It returns a Promise resolving to a boolean representing whether the setting of the badge succeeded.
* @header badge
*/
export default async function setBadgeCountAsync(
badgeCount: number,
options?: SetBadgeCountOptions
): Promise<boolean> {
if (!BadgeModule.setBadgeCountAsync) {
throw new UnavailabilityError('ExpoNotifications', 'setBadgeCountAsync');
}
return await BadgeModule.setBadgeCountAsync(badgeCount, options?.[Platform.OS]);
}

View File

@@ -0,0 +1,37 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationCategoriesModule from './NotificationCategoriesModule';
import {
NotificationCategory,
NotificationAction,
NotificationCategoryOptions,
} from './Notifications.types';
/**
* Sets the new notification category.
* @param identifier A string to associate as the ID of this category. You will pass this string in as the `categoryIdentifier`
* in your [`NotificationContent`](#notificationcontent) to associate a notification with this category.
* > Don't use the characters `:` or `-` in your category identifier. If you do, categories might not work as expected.
* @param actions An array of [`NotificationAction`s](#notificationaction), which describe the actions associated with this category.
* @param options An optional object of additional configuration options for your category.
* @return A Promise which resolves to the category you just have created, or null on web
* @platform android
* @platform ios
* @platform web
* @header categories
*/
export default async function setNotificationCategoryAsync(
identifier: string,
actions: NotificationAction[],
options?: NotificationCategoryOptions
): Promise<NotificationCategory> {
if (!NotificationCategoriesModule.setNotificationCategoryAsync) {
throw new UnavailabilityError('Notifications', 'setNotificationCategoryAsync');
}
return await NotificationCategoriesModule.setNotificationCategoryAsync(
identifier,
actions,
options
);
}

View File

@@ -0,0 +1,15 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationChannelManager from './NotificationChannelManager';
import { NotificationChannelInput, NotificationChannel } from './NotificationChannelManager.types';
export default async function setNotificationChannelAsync(
channelId: string,
channel: NotificationChannelInput
): Promise<NotificationChannel | null> {
if (!NotificationChannelManager.setNotificationChannelAsync) {
throw new UnavailabilityError('Notifications', 'setNotificationChannelAsync');
}
return await NotificationChannelManager.setNotificationChannelAsync(channelId, channel);
}

View File

@@ -0,0 +1,28 @@
import { NotificationChannel, NotificationChannelInput } from './NotificationChannelManager.types';
/**
* Assigns the channel configuration to a channel of a specified name (creating it if need be).
* This method lets you assign given notification channel to a notification channel group.
*
* > **Note:** For some settings to be applied on all Android versions, it may be necessary to duplicate the configuration across both
* > a single notification and its respective notification channel.
*
* For example, for a notification to play a custom sound on Android versions **below** 8.0,
* the custom notification sound has to be set on the notification (through the [`NotificationContentInput`](#notificationcontentinput)),
* and for the custom sound to play on Android versions **above** 8.0, the relevant notification channel must have the custom sound configured
* (through the [`NotificationChannelInput`](#notificationchannelinput)). For more information,
* see [Set custom notification sounds on Android](#set-custom-notification-sounds).
* @param channelId The channel identifier.
* @param channel Object representing the channel's configuration.
* @return A Promise which resolving to the object (of type [`NotificationChannel`](#notificationchannel)) describing the modified channel
* or to `null` if the platform does not support notification channels.
* @platform android
* @header channels
*/
export default async function setNotificationChannelAsync(
channelId: string,
channel: NotificationChannelInput
): Promise<NotificationChannel | null> {
console.debug('Notification channels feature is only supported on Android.');
return null;
}

View File

@@ -0,0 +1,18 @@
import { UnavailabilityError } from 'expo-modules-core';
import NotificationChannelGroupManager from './NotificationChannelGroupManager';
import {
NotificationChannelGroup,
NotificationChannelGroupInput,
} from './NotificationChannelGroupManager.types';
export default async function setNotificationChannelGroupAsync(
groupId: string,
group: NotificationChannelGroupInput
): Promise<NotificationChannelGroup | null> {
if (!NotificationChannelGroupManager.setNotificationChannelGroupAsync) {
throw new UnavailabilityError('Notifications', 'setNotificationChannelGroupAsync');
}
return await NotificationChannelGroupManager.setNotificationChannelGroupAsync(groupId, group);
}

View File

@@ -0,0 +1,21 @@
import {
NotificationChannelGroup,
NotificationChannelGroupInput,
} from './NotificationChannelGroupManager.types';
/**
* Assigns the channel group configuration to a channel group of a specified name (creating it if need be).
* @param groupId The channel group's identifier.
* @param group Object representing the channel group configuration.
* @return A `Promise` resolving to the object (of type [`NotificationChannelGroup`](#notificationchannelgroup))
* describing the modified channel group or to `null` if the platform does not support notification channels.
* @platform android
* @header channels
*/
export default async function setNotificationChannelGroupAsync(
groupId: string,
group: NotificationChannelGroupInput
): Promise<NotificationChannelGroup | null> {
console.debug('Notification channels feature is only supported on Android.');
return null;
}

View File

@@ -0,0 +1,11 @@
import { UnavailabilityError } from 'expo-modules-core';
import PushTokenManager from './PushTokenManager';
// @docsMissing
export default async function unregisterForNotificationsAsync(): Promise<void> {
if (!PushTokenManager.unregisterForNotificationsAsync) {
throw new UnavailabilityError('ExpoNotifications', 'unregisterForNotificationsAsync');
}
return PushTokenManager.unregisterForNotificationsAsync();
}

View File

@@ -0,0 +1,16 @@
import { UnavailabilityError } from 'expo-modules-core';
import BackgroundNotificationTasksModule from './BackgroundNotificationTasksModule';
/**
* Used to unregister tasks registered with `registerTaskAsync` method.
* @param taskName The string you passed to `registerTaskAsync` as the `taskName` parameter.
* @header inBackground
*/
export default async function unregisterTaskAsync(taskName: string): Promise<null> {
if (!BackgroundNotificationTasksModule.unregisterTaskAsync) {
throw new UnavailabilityError('Notifications', 'unregisterTaskAsync');
}
return await BackgroundNotificationTasksModule.unregisterTaskAsync(taskName);
}

View File

@@ -0,0 +1,94 @@
import { useLayoutEffect, useState } from 'react';
import { NotificationResponse } from './Notifications.types';
import {
addNotificationResponseReceivedListener,
addNotificationResponseClearedListener,
getLastNotificationResponseAsync,
} from './NotificationsEmitter';
type MaybeNotificationResponse = NotificationResponse | null | undefined;
/**
* A React hook always returns the notification response that was received most recently
* (a notification response designates an interaction with a notification, such as tapping on it).
*
* > If you don't want to use a hook, you can use `Notifications.getLastNotificationResponseAsync()` instead.
*
* @return The hook may return one of these three types/values:
* - `undefined` - until we're sure of what to return,
* - `null` - if no notification response has been received yet,
* - a [`NotificationResponse`](#notificationresponse) object - if a notification response was received.
*
* @example
* Responding to a notification tap by opening a URL that could be put into the notification's `data`
* (opening the URL is your responsibility and is not a part of the `expo-notifications` API):
* ```jsx
* import * as Notifications from 'expo-notifications';
* import { Linking } from 'react-native';
*
* export default function App() {
* const lastNotificationResponse = Notifications.useLastNotificationResponse();
* React.useEffect(() => {
* if (
* lastNotificationResponse &&
* lastNotificationResponse.notification.request.content.data.url &&
* lastNotificationResponse.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER
* ) {
* Linking.openURL(lastNotificationResponse.notification.request.content.data.url);
* }
* }, [lastNotificationResponse]);
* return (
* // Your app content
* );
* }
* ```
* @header listen
*/
export default function useLastNotificationResponse() {
const [lastNotificationResponse, setLastNotificationResponse] =
useState<MaybeNotificationResponse>(undefined);
// Pure function that returns the new response if it is different from the previous,
// otherwise return the previous response
const newResponseIfNeeded = (
prevResponse: MaybeNotificationResponse,
newResponse: MaybeNotificationResponse
) => {
// If the new response is undefined or null, no need for update
if (!newResponse) {
return prevResponse;
}
// If the previous response is undefined or null and the new response is not, we should update
if (!prevResponse) {
return newResponse;
}
return prevResponse.notification.request.identifier !==
newResponse.notification.request.identifier
? newResponse
: prevResponse;
};
// useLayoutEffect ensures the listener is registered as soon as possible
useLayoutEffect(() => {
// Get the last response first, in case it was set earlier (even in native code on startup)
// before this renders
getLastNotificationResponseAsync?.().then((response) =>
setLastNotificationResponse((prevResponse) => newResponseIfNeeded(prevResponse, response))
);
// Set up listener for responses that come in, and set the last response if needed
const subscription = addNotificationResponseReceivedListener((response) =>
setLastNotificationResponse((prevResponse) => newResponseIfNeeded(prevResponse, response))
);
const clearResponseSubscription = addNotificationResponseClearedListener(() => {
setLastNotificationResponse(undefined);
});
return () => {
subscription.remove();
clearResponseSubscription.remove();
};
}, []);
return lastNotificationResponse;
}

View File

@@ -0,0 +1,73 @@
import {
Notification,
NotificationContent,
NotificationRequest,
NotificationResponse,
} from '../Notifications.types';
/**
* @hidden
*
* Does any required processing of a notification response from native code
* before it is passed to a notification response listener, or to the
* last notification response hook.
*
* @param response The raw response passed in from native code
* @returns the mapped response.
*/
export const mapNotificationResponse = (response: NotificationResponse) => {
return {
...response,
notification: mapNotification(response.notification),
};
};
/**
* @hidden
*
* Does any required processing of a notification from native code
* before it is passed to a notification listener.
*
* @param notification The raw notification passed in from native code
* @returns the mapped notification.
*/
export const mapNotification = (notification: Notification) => ({
...notification,
request: mapNotificationRequest(notification.request),
});
/**
* @hidden
*
* Does any required processing of a notification request from native code
* before it is passed to other JS code.
*
* @param request The raw request passed in from native code
* @returns the mapped request.
*/
export const mapNotificationRequest = (request: NotificationRequest) => ({
...request,
content: mapNotificationContent(request.content),
});
/**
* @hidden
* Does any required processing of notification content from native code
* before being passed to other JS code.
*
* @param content The raw content passed in from native code
* @returns the mapped content.
*/
export const mapNotificationContent = (content: NotificationContent) => {
const mappedContent: NotificationContent & { dataString?: string } = { ...content };
try {
const dataString = mappedContent['dataString'];
if (typeof dataString === 'string') {
mappedContent.data = JSON.parse(dataString);
delete mappedContent.dataString;
}
} catch (e: any) {
console.log(`Error in notification: ${e}`);
}
return mappedContent;
};

View File

@@ -0,0 +1,147 @@
import { computeNextBackoffInterval } from '@ide/backoff';
import * as Application from 'expo-application';
import { CodedError, Platform, UnavailabilityError } from 'expo-modules-core';
import ServerRegistrationModule from '../ServerRegistrationModule';
import { DevicePushToken } from '../Tokens.types';
const updateDevicePushTokenUrl = 'https://exp.host/--/api/v2/push/updateDeviceToken';
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()
);
}
// Retry if request failed
if (!response.ok) {
retry();
}
} catch (e) {
// 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 (e.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:',
e
);
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;
}