1import * as Application from 'expo-application';
2import Constants from 'expo-constants';
3import { Platform, CodedError, UnavailabilityError } from 'expo-modules-core';
4
5import { setAutoServerRegistrationEnabledAsync } from './DevicePushTokenAutoRegistration.fx';
6import ServerRegistrationModule from './ServerRegistrationModule';
7import { DevicePushToken, ExpoPushToken, ExpoPushTokenOptions } from './Tokens.types';
8import getDevicePushTokenAsync from './getDevicePushTokenAsync';
9
10const productionBaseUrl = 'https://exp.host/--/api/v2/';
11
12/**
13 * Returns an Expo token that can be used to send a push notification to the device using Expo's push notifications service.
14 *
15 * This method makes requests to the Expo's servers. It can get rejected in cases where the request itself fails
16 * (for example, due to the device being offline, experiencing a network timeout, or other HTTPS request failures).
17 * To provide offline support to your users, you should `try/catch` this method and implement retry logic to attempt
18 * to get the push token later, once the device is back online.
19 *
20 * > For Expo's backend to be able to send notifications to your app, you will need to provide it with push notification keys.
21 * For more information, see [credentials](/push-notifications/push-notifications-setup/#get-credentials-for-development-builds) in the push notifications setup.
22 *
23 * @param options Object allowing you to pass in push notification configuration.
24 * @return Returns a `Promise` that resolves to an object representing acquired push token.
25 * @header fetch
26 *
27 * @example
28 * ```ts
29 * import * as Notifications from 'expo-notifications';
30 *
31 * export async function registerForPushNotificationsAsync(userId: string) {
32 *   const expoPushToken = await Notifications.getExpoPushTokenAsync({
33 *    projectId: 'your-project-id',
34 *   });
35 *
36 *   await fetch('https://example.com/', {
37 *     method: 'POST',
38 *     headers: {
39 *       'Content-Type': 'application/json',
40 *     },
41 *     body: JSON.stringify({
42 *       userId,
43 *       expoPushToken,
44 *     }),
45 *   });
46 * }
47 * ```
48 */
49export default async function getExpoPushTokenAsync(
50  options: ExpoPushTokenOptions = {}
51): Promise<ExpoPushToken> {
52  const devicePushToken = options.devicePushToken || (await getDevicePushTokenAsync());
53
54  const deviceId = options.deviceId || (await getDeviceIdAsync());
55  const projectId = options.projectId || Constants.easConfig?.projectId;
56
57  if (!projectId) {
58    console.warn(
59      'Calling getExpoPushTokenAsync without specifying a projectId is deprecated and will no longer be supported in SDK 49+'
60    );
61  }
62
63  if (!projectId) {
64    throw new CodedError(
65      'ERR_NOTIFICATIONS_NO_EXPERIENCE_ID',
66      "No 'projectId' found. If 'projectId' can't be inferred from the manifest (eg. in bare workflow), you have to pass it in yourself."
67    );
68  }
69
70  const applicationId = options.applicationId || Application.applicationId;
71  if (!applicationId) {
72    throw new CodedError(
73      'ERR_NOTIFICATIONS_NO_APPLICATION_ID',
74      "No applicationId found. If it can't be inferred from native configuration by expo-application, you have to pass it in yourself."
75    );
76  }
77  const type = options.type || getTypeOfToken(devicePushToken);
78  const development = options.development || (await shouldUseDevelopmentNotificationService());
79
80  const baseUrl = options.baseUrl ?? productionBaseUrl;
81  const url = options.url ?? `${baseUrl}push/getExpoPushToken`;
82
83  const body = {
84    type,
85    deviceId: deviceId.toLowerCase(),
86    development,
87    appId: applicationId,
88    deviceToken: getDeviceToken(devicePushToken),
89    projectId,
90  };
91
92  const response = await fetch(url, {
93    method: 'POST',
94    headers: {
95      'content-type': 'application/json',
96    },
97    body: JSON.stringify(body),
98  }).catch((error) => {
99    throw new CodedError(
100      'ERR_NOTIFICATIONS_NETWORK_ERROR',
101      `Error encountered while fetching Expo token: ${error}.`
102    );
103  });
104
105  if (!response.ok) {
106    const statusInfo = response.statusText || response.status;
107    let body: string | undefined = undefined;
108    try {
109      body = await response.text();
110    } catch {
111      // do nothing
112    }
113    throw new CodedError(
114      'ERR_NOTIFICATIONS_SERVER_ERROR',
115      `Error encountered while fetching Expo token, expected an OK response, received: ${statusInfo} (body: "${body}").`
116    );
117  }
118
119  const expoPushToken = getExpoPushToken(await parseResponse(response));
120
121  try {
122    if (options.url || options.baseUrl) {
123      console.debug(
124        `[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.`
125      );
126    } else {
127      await setAutoServerRegistrationEnabledAsync(true);
128    }
129  } catch (e) {
130    console.warn(
131      '[expo-notifications] Could not enable automatically registering new device tokens with the Expo notification service',
132      e
133    );
134  }
135
136  return {
137    type: 'expo',
138    data: expoPushToken,
139  };
140}
141
142async function parseResponse(response: Response) {
143  try {
144    return await response.json();
145  } catch {
146    try {
147      throw new CodedError(
148        'ERR_NOTIFICATIONS_SERVER_ERROR',
149        `Expected a JSON response from server when fetching Expo token, received body: ${JSON.stringify(
150          await response.text()
151        )}.`
152      );
153    } catch {
154      throw new CodedError(
155        'ERR_NOTIFICATIONS_SERVER_ERROR',
156        `Expected a JSON response from server when fetching Expo token, received response: ${JSON.stringify(
157          response
158        )}.`
159      );
160    }
161  }
162}
163
164function getExpoPushToken(data: any) {
165  if (
166    !data ||
167    !(typeof data === 'object') ||
168    !data.data ||
169    !(typeof data.data === 'object') ||
170    !data.data.expoPushToken ||
171    !(typeof data.data.expoPushToken === 'string')
172  ) {
173    throw new CodedError(
174      'ERR_NOTIFICATIONS_SERVER_ERROR',
175      `Malformed response from server, expected "{ data: { expoPushToken: string } }", received: ${JSON.stringify(
176        data,
177        null,
178        2
179      )}.`
180    );
181  }
182
183  return data.data.expoPushToken as string;
184}
185
186// Same as in DevicePushTokenAutoRegistration
187async function getDeviceIdAsync() {
188  try {
189    if (!ServerRegistrationModule.getInstallationIdAsync) {
190      throw new UnavailabilityError('ExpoServerRegistrationModule', 'getInstallationIdAsync');
191    }
192
193    return await ServerRegistrationModule.getInstallationIdAsync();
194  } catch (e) {
195    throw new CodedError(
196      'ERR_NOTIF_DEVICE_ID',
197      `Could not have fetched installation ID of the application: ${e}.`
198    );
199  }
200}
201
202function getDeviceToken(devicePushToken: DevicePushToken) {
203  if (typeof devicePushToken.data === 'string') {
204    return devicePushToken.data;
205  }
206
207  return JSON.stringify(devicePushToken.data);
208}
209
210// Same as in DevicePushTokenAutoRegistration
211async function shouldUseDevelopmentNotificationService() {
212  if (Platform.OS === 'ios') {
213    try {
214      const notificationServiceEnvironment =
215        await Application.getIosPushNotificationServiceEnvironmentAsync();
216      if (notificationServiceEnvironment === 'development') {
217        return true;
218      }
219    } catch {
220      // We can't do anything here, we'll fallback to false then.
221    }
222  }
223
224  return false;
225}
226
227// Same as in DevicePushTokenAutoRegistration
228function getTypeOfToken(devicePushToken: DevicePushToken) {
229  switch (devicePushToken.type) {
230    case 'ios':
231      return 'apns';
232    case 'android':
233      return 'fcm';
234    // This probably will error on server, but let's make this function future-safe.
235    default:
236      return devicePushToken.type;
237  }
238}
239