1import Constants from 'expo-constants';
2import { CodedError, Platform, SyntheticPlatformEmitter } from 'expo-modules-core';
3
4import { DevicePushToken } from './Tokens.types';
5
6export default async function getDevicePushTokenAsync(): Promise<DevicePushToken> {
7  const data = await _subscribeDeviceToPushNotificationsAsync();
8  SyntheticPlatformEmitter.emit('onDevicePushToken', { devicePushToken: data });
9  return { type: Platform.OS, data };
10}
11
12function guardPermission() {
13  if (!('Notification' in window)) {
14    throw new CodedError(
15      'ERR_UNAVAILABLE',
16      'The Web Notifications API is not available on this device.'
17    );
18  }
19  if (!navigator.serviceWorker) {
20    throw new CodedError(
21      'ERR_UNAVAILABLE',
22      '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.'
23    );
24  }
25  if (Notification.permission !== 'granted') {
26    throw new CodedError(
27      'ERR_NOTIFICATIONS_PERMISSION_DENIED',
28      `Cannot use web notifications without permissions granted. Request permissions with "expo-permissions".`
29    );
30  }
31}
32
33async function _subscribeDeviceToPushNotificationsAsync(): Promise<DevicePushToken['data']> {
34  const vapidPublicKey: string | null =
35    // @ts-expect-error: TODO: not on the schema
36    Constants.manifest?.notification?.vapidPublicKey ??
37    // @ts-expect-error: TODO: not on the schema
38    Constants.manifest2?.extra?.expoClient?.notification?.vapidPublicKey;
39  if (!vapidPublicKey) {
40    throw new CodedError(
41      'ERR_NOTIFICATIONS_PUSH_WEB_MISSING_CONFIG',
42      '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/.'
43    );
44  }
45
46  const serviceWorkerPath =
47    // @ts-expect-error: TODO: not on the schema
48    Constants.manifest?.notification.serviceWorkerPath ??
49    // @ts-expect-error: TODO: not on the schema
50    Constants.manifest2?.extra?.expoClient?.notification?.serviceWorkerPath;
51  if (!serviceWorkerPath) {
52    throw new CodedError(
53      'ERR_NOTIFICATIONS_PUSH_MISSING_CONFIGURATION',
54      '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.'
55    );
56  }
57  guardPermission();
58
59  let registration: ServiceWorkerRegistration | null = null;
60  try {
61    registration = await navigator.serviceWorker.register(serviceWorkerPath);
62  } catch (error) {
63    throw new CodedError(
64      'ERR_NOTIFICATIONS_PUSH_REGISTRATION_FAILED',
65      `Could not register this device for push notifications because the service worker (${serviceWorkerPath}) could not be registered: ${error}`
66    );
67  }
68  await navigator.serviceWorker.ready;
69
70  if (!registration.active) {
71    throw new CodedError(
72      'ERR_NOTIFICATIONS_PUSH_REGISTRATION_FAILED',
73      'Could not register this device for push notifications because the service worker is not active.'
74    );
75  }
76
77  const subscribeOptions = {
78    userVisibleOnly: true,
79    applicationServerKey: _urlBase64ToUint8Array(vapidPublicKey),
80  };
81  let pushSubscription: PushSubscription | null = null;
82  try {
83    pushSubscription = await registration.pushManager.subscribe(subscribeOptions);
84  } catch (error) {
85    throw new CodedError(
86      'ERR_NOTIFICATIONS_PUSH_REGISTRATION_FAILED',
87      'The device was unable to register for remote notifications with the browser endpoint. (' +
88        error +
89        ')'
90    );
91  }
92  const pushSubscriptionJson = pushSubscription.toJSON();
93
94  const subscriptionObject = {
95    endpoint: pushSubscriptionJson.endpoint,
96    keys: {
97      p256dh: pushSubscriptionJson.keys!.p256dh,
98      auth: pushSubscriptionJson.keys!.auth,
99    },
100  };
101
102  // Store notification icon string in service worker.
103  // This message is received by `/expo-service-worker.js`.
104  // We wrap it with `fromExpoWebClient` to make sure other message
105  // will not override content such as `notificationIcon`.
106  // https://stackoverflow.com/a/35729334/2603230
107  const notificationIcon = (
108    Constants.manifest?.notification ??
109    Constants.manifest2?.extra?.expoClient?.notification ??
110    {}
111  ).icon;
112  await registration.active.postMessage(
113    JSON.stringify({ fromExpoWebClient: { notificationIcon } })
114  );
115
116  return subscriptionObject;
117}
118
119// https://github.com/web-push-libs/web-push#using-vapid-key-for-applicationserverkey
120function _urlBase64ToUint8Array(base64String: string): Uint8Array {
121  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
122  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
123
124  const rawData = window.atob(base64);
125  const outputArray = new Uint8Array(rawData.length);
126
127  for (let i = 0; i < rawData.length; ++i) {
128    outputArray[i] = rawData.charCodeAt(i);
129  }
130  return outputArray;
131}
132