1import { computeNextBackoffInterval } from '@ide/backoff';
2import * as Application from 'expo-application';
3import { CodedError, Platform, UnavailabilityError } from 'expo-modules-core';
4
5import ServerRegistrationModule from '../ServerRegistrationModule';
6import { DevicePushToken } from '../Tokens.types';
7
8const updateDevicePushTokenUrl = 'https://exp.host/--/api/v2/push/updateDeviceToken';
9
10export async function updateDevicePushTokenAsync(signal: AbortSignal, token: DevicePushToken) {
11  const doUpdateDevicePushTokenAsync = async (retry: () => void) => {
12    const [development, deviceId] = await Promise.all([
13      shouldUseDevelopmentNotificationService(),
14      getDeviceIdAsync(),
15    ]);
16    const body = {
17      deviceId: deviceId.toLowerCase(),
18      development,
19      deviceToken: token.data,
20      appId: Application.applicationId,
21      type: getTypeOfToken(token),
22    };
23
24    try {
25      const response = await fetch(updateDevicePushTokenUrl, {
26        method: 'POST',
27        headers: {
28          'content-type': 'application/json',
29        },
30        body: JSON.stringify(body),
31        signal,
32      });
33
34      // Help debug erroring servers
35      if (!response.ok) {
36        console.debug(
37          '[expo-notifications] Error encountered while updating the device push token with the server:',
38          await response.text()
39        );
40      }
41
42      // Retry if request failed
43      if (!response.ok) {
44        retry();
45      }
46    } catch (e) {
47      // Error returned if the request is aborted should be an 'AbortError'. In
48      // React Native fetch is polyfilled using `whatwg-fetch` which:
49      // - creates `AbortError`s like this
50      //   https://github.com/github/fetch/blob/75d9455d380f365701151f3ac85c5bda4bbbde76/fetch.js#L505
51      // - which creates exceptions like
52      //   https://github.com/github/fetch/blob/75d9455d380f365701151f3ac85c5bda4bbbde76/fetch.js#L490-L494
53      if (e.name === 'AbortError') {
54        // We don't consider AbortError a failure, it's a sign somewhere else the
55        // request is expected to succeed and we don't need this one, so let's
56        // just return.
57        return;
58      }
59
60      console.warn(
61        '[expo-notifications] Error thrown while updating the device push token with the server:',
62        e
63      );
64
65      retry();
66    }
67  };
68
69  let shouldTry = true;
70  const retry = () => {
71    shouldTry = true;
72  };
73
74  let retriesCount = 0;
75  const initialBackoff = 500; // 0.5 s
76  const backoffOptions = {
77    maxBackoff: 2 * 60 * 1000, // 2 minutes
78  };
79  let nextBackoffInterval = computeNextBackoffInterval(
80    initialBackoff,
81    retriesCount,
82    backoffOptions
83  );
84
85  while (shouldTry && !signal.aborted) {
86    // Will be set to true by `retry` if it's called
87    shouldTry = false;
88    await doUpdateDevicePushTokenAsync(retry);
89
90    // Do not wait if we won't retry
91    if (shouldTry && !signal.aborted) {
92      nextBackoffInterval = computeNextBackoffInterval(
93        initialBackoff,
94        retriesCount,
95        backoffOptions
96      );
97      retriesCount += 1;
98      await new Promise((resolve) => setTimeout(resolve, nextBackoffInterval));
99    }
100  }
101}
102
103// Same as in getExpoPushTokenAsync
104async function getDeviceIdAsync() {
105  try {
106    if (!ServerRegistrationModule.getInstallationIdAsync) {
107      throw new UnavailabilityError('ExpoServerRegistrationModule', 'getInstallationIdAsync');
108    }
109
110    return await ServerRegistrationModule.getInstallationIdAsync();
111  } catch (e) {
112    throw new CodedError(
113      'ERR_NOTIFICATIONS_DEVICE_ID',
114      `Could not fetch the installation ID of the application: ${e}.`
115    );
116  }
117}
118
119// Same as in getExpoPushTokenAsync
120function getTypeOfToken(devicePushToken: DevicePushToken) {
121  switch (devicePushToken.type) {
122    case 'ios':
123      return 'apns';
124    case 'android':
125      return 'fcm';
126    // This probably will error on server, but let's make this function future-safe.
127    default:
128      return devicePushToken.type;
129  }
130}
131
132// Same as in getExpoPushTokenAsync
133async function shouldUseDevelopmentNotificationService() {
134  if (Platform.OS === 'ios') {
135    try {
136      const notificationServiceEnvironment =
137        await Application.getIosPushNotificationServiceEnvironmentAsync();
138      if (notificationServiceEnvironment === 'development') {
139        return true;
140      }
141    } catch {
142      // We can't do anything here, we'll fallback to false then.
143    }
144  }
145
146  return false;
147}
148