xref: /expo/packages/expo-updates/src/Updates.ts (revision 033ea1fc)
1import {
2  DeviceEventEmitter,
3  CodedError,
4  NativeModulesProxy,
5  UnavailabilityError,
6} from 'expo-modules-core';
7import { EventEmitter, EventSubscription } from 'fbemitter';
8
9import ExpoUpdates from './ExpoUpdates';
10import {
11  LocalAssets,
12  Manifest,
13  UpdateCheckResult,
14  UpdateEvent,
15  UpdateFetchResult,
16  UpdatesCheckAutomaticallyValue,
17  UpdatesLogEntry,
18} from './Updates.types';
19
20export * from './Updates.types';
21
22/**
23 * The UUID that uniquely identifies the currently running update if `expo-updates` is enabled. The
24 * UUID is represented in its canonical string form (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) and
25 * will always use lowercase letters. In development mode, or any other environment in which
26 * `expo-updates` is disabled, this value is `null`.
27 */
28export const updateId: string | null =
29  ExpoUpdates.updateId && typeof ExpoUpdates.updateId === 'string'
30    ? ExpoUpdates.updateId.toLowerCase()
31    : null;
32
33/**
34 * The name of the release channel currently configured in this standalone or bare app when using
35 * classic updates. When using Expo Updates, the value of this field is always `"default"`.
36 */
37export const releaseChannel: string = ExpoUpdates.releaseChannel ?? 'default';
38
39/**
40 * The channel name of the current build, if configured for use with EAS Update. Null otherwise.
41 */
42export const channel: string | null = ExpoUpdates.channel ?? null;
43
44/**
45 * The runtime version of the current build.
46 */
47export const runtimeVersion: string | null = ExpoUpdates.runtimeVersion ?? null;
48
49const _checkAutomaticallyMapNativeToJS = {
50  ALWAYS: 'ON_LOAD',
51  ERROR_RECOVERY_ONLY: 'ON_ERROR_RECOVERY',
52  NEVER: 'NEVER',
53  WIFI_ONLY: 'WIFI_ONLY',
54};
55
56/**
57 * Determines if and when expo-updates checks for and downloads updates automatically on startup.
58 */
59export const checkAutomatically: UpdatesCheckAutomaticallyValue | null =
60  _checkAutomaticallyMapNativeToJS[ExpoUpdates.checkAutomatically] ?? null;
61
62// @docsMissing
63/**
64 * @hidden
65 */
66export const localAssets: LocalAssets = ExpoUpdates.localAssets ?? {};
67
68/**
69 * `expo-updates` does its very best to always launch monotonically newer versions of your app so
70 * you don't need to worry about backwards compatibility when you put out an update. In very rare
71 * cases, it's possible that `expo-updates` may need to fall back to the update that's embedded in
72 * the app binary, even after newer updates have been downloaded and run (an "emergency launch").
73 * This boolean will be `true` if the app is launching under this fallback mechanism and `false`
74 * otherwise. If you are concerned about backwards compatibility of future updates to your app, you
75 * can use this constant to provide special behavior for this rare case.
76 */
77export const isEmergencyLaunch: boolean = ExpoUpdates.isEmergencyLaunch || false;
78
79/**
80 * This will be true if the currently running update is the one embedded in the build,
81 * and not one downloaded from the updates server.
82 */
83export const isEmbeddedLaunch: boolean = ExpoUpdates.isEmbeddedLaunch || false;
84
85// @docsMissing
86/**
87 * @hidden
88 */
89export const isUsingEmbeddedAssets: boolean = ExpoUpdates.isUsingEmbeddedAssets || false;
90
91/**
92 * If `expo-updates` is enabled, this is the
93 * [manifest](/versions/latest/sdk/constants/#manifest) (or
94 * [classic manifest](/versions/latest/sdk/constants/#appmanifest))
95 * object for the update that's currently running.
96 *
97 * In development mode, or any other environment in which `expo-updates` is disabled, this object is
98 * empty.
99 */
100export const manifest: Partial<Manifest> =
101  (ExpoUpdates.manifestString ? JSON.parse(ExpoUpdates.manifestString) : ExpoUpdates.manifest) ??
102  {};
103
104/**
105 * If `expo-updates` is enabled, this is a `Date` object representing the creation time of the update that's currently running (whether it was embedded or downloaded at runtime).
106 *
107 * In development mode, or any other environment in which `expo-updates` is disabled, this value is
108 * null.
109 */
110export const createdAt: Date | null = ExpoUpdates.commitTime
111  ? new Date(ExpoUpdates.commitTime)
112  : null;
113
114const isUsingDeveloperTool = !!(manifest as any).developer?.tool;
115const isUsingExpoDevelopmentClient = NativeModulesProxy.ExponentConstants?.appOwnership === 'expo';
116const manualUpdatesInstructions = isUsingExpoDevelopmentClient
117  ? 'To test manual updates, publish your project using `expo publish` and open the published ' +
118    'version in this development client.'
119  : 'To test manual updates, make a release build with `npm run ios --configuration Release` or ' +
120    '`npm run android --variant Release`.';
121
122/**
123 * Instructs the app to reload using the most recently downloaded version. This is useful for
124 * triggering a newly downloaded update to launch without the user needing to manually restart the
125 * app.
126 *
127 * It is not recommended to place any meaningful logic after a call to `await
128 * Updates.reloadAsync()`. This is because the promise is resolved after verifying that the app can
129 * be reloaded, and immediately before posting an asynchronous task to the main thread to actually
130 * reload the app. It is unsafe to make any assumptions about whether any more JS code will be
131 * executed after the `Updates.reloadAsync` method call resolves, since that depends on the OS and
132 * the state of the native module and main threads.
133 *
134 * This method cannot be used in development mode, and the returned promise will be rejected if you
135 * try to do so.
136 *
137 * @return A promise that fulfills right before the reload instruction is sent to the JS runtime, or
138 * rejects if it cannot find a reference to the JS runtime. If the promise is rejected in production
139 * mode, it most likely means you have installed the module incorrectly. Double check you've
140 * followed the installation instructions. In particular, on iOS ensure that you set the `bridge`
141 * property on `EXUpdatesAppController` with a pointer to the `RCTBridge` you want to reload, and on
142 * Android ensure you either call `UpdatesController.initialize` with the instance of
143 * `ReactApplication` you want to reload, or call `UpdatesController.setReactNativeHost` with the
144 * proper instance of `ReactNativeHost`.
145 */
146export async function reloadAsync(): Promise<void> {
147  if (!ExpoUpdates.reload) {
148    throw new UnavailabilityError('Updates', 'reloadAsync');
149  }
150  if (!ExpoUpdates?.nativeDebug && (__DEV__ || isUsingExpoDevelopmentClient)) {
151    throw new CodedError(
152      'ERR_UPDATES_DISABLED',
153      `You cannot use the Updates module in development mode in a production app. ${manualUpdatesInstructions}`
154    );
155  }
156  await ExpoUpdates.reload();
157}
158
159/**
160 * Checks the server to see if a newly deployed update to your project is available. Does not
161 * actually download the update. This method cannot be used in development mode, and the returned
162 * promise will be rejected if you try to do so.
163 *
164 * Checking for an update uses a device's bandwidth and battery life like any network call.
165 * Additionally, updates served by Expo may be rate limited. A good rule of thumb to check for
166 * updates judiciously is to check when the user launches or foregrounds the app. Avoid polling for
167 * updates in a frequent loop.
168 *
169 * @return A promise that fulfills with an [`UpdateCheckResult`](#updatecheckresult) object.
170 *
171 * The promise rejects if the app is in development mode, or if there is an unexpected error or
172 * timeout communicating with the server.
173 */
174export async function checkForUpdateAsync(): Promise<UpdateCheckResult> {
175  if (!ExpoUpdates.checkForUpdateAsync) {
176    throw new UnavailabilityError('Updates', 'checkForUpdateAsync');
177  }
178  if (!ExpoUpdates?.nativeDebug && (__DEV__ || isUsingDeveloperTool)) {
179    throw new CodedError(
180      'ERR_UPDATES_DISABLED',
181      `You cannot check for updates in development mode. ${manualUpdatesInstructions}`
182    );
183  }
184
185  const result = await ExpoUpdates.checkForUpdateAsync();
186  if (result.manifestString) {
187    result.manifest = JSON.parse(result.manifestString);
188    delete result.manifestString;
189  }
190
191  return result;
192}
193
194/**
195 * Retrieves the current extra params.
196 */
197export async function getExtraParamsAsync(): Promise<{ [key: string]: string }> {
198  if (!ExpoUpdates.getExtraParamsAsync) {
199    throw new UnavailabilityError('Updates', 'getExtraParamsAsync');
200  }
201
202  return await ExpoUpdates.getExtraParamsAsync();
203}
204
205/**
206 * Sets an extra param if value is non-null, otherwise unsets the param.
207 * Extra params are sent in a header of update requests.
208 * The update server may use these params when evaluating logic to determine which update to serve.
209 * EAS Update merges these params into the fields used to evaluate channel–branch mapping logic.
210 *
211 * @example An app may want to add a feature where users can opt-in to beta updates. In this instance,
212 * extra params could be set to `{userType: 'beta'}`, and then the server can use this information
213 * when deciding which update to serve. If using EAS Update, the channel-branch mapping can be set to
214 * discriminate branches based on the `userType`.
215 */
216export async function setExtraParamAsync(
217  key: string,
218  value: string | null | undefined
219): Promise<void> {
220  if (!ExpoUpdates.setExtraParamAsync) {
221    throw new UnavailabilityError('Updates', 'setExtraParamAsync');
222  }
223
224  return await ExpoUpdates.setExtraParamAsync(key, value ?? null);
225}
226
227/**
228 * Retrieves the most recent expo-updates log entries.
229 *
230 * @param maxAge Sets the max age of retrieved log entries in milliseconds. Default to 3600000 ms (1 hour).
231 *
232 * @return A promise that fulfills with an array of [`UpdatesLogEntry`](#updateslogentry) objects;
233 *
234 * The promise rejects if there is an unexpected error in retrieving the logs.
235 */
236export async function readLogEntriesAsync(maxAge: number = 3600000): Promise<UpdatesLogEntry[]> {
237  if (!ExpoUpdates.readLogEntriesAsync) {
238    throw new UnavailabilityError('Updates', 'readLogEntriesAsync');
239  }
240  return await ExpoUpdates.readLogEntriesAsync(maxAge);
241}
242
243/**
244 * Clears existing expo-updates log entries.
245 *
246 * > For now, this operation does nothing on the client.  Once log persistence has been
247 * > implemented, this operation will actually remove existing logs.
248 *
249 * @return A promise that fulfills if the clear operation was successful.
250 *
251 * The promise rejects if there is an unexpected error in clearing the logs.
252 *
253 */
254export async function clearLogEntriesAsync(): Promise<void> {
255  if (!ExpoUpdates.clearLogEntriesAsync) {
256    throw new UnavailabilityError('Updates', 'clearLogEntriesAsync');
257  }
258  await ExpoUpdates.clearLogEntriesAsync();
259}
260
261/**
262 * Downloads the most recently deployed update to your project from server to the device's local
263 * storage. This method cannot be used in development mode, and the returned promise will be
264 * rejected if you try to do so.
265 *
266 * @return A promise that fulfills with an [`UpdateFetchResult`](#updatefetchresult) object.
267 *
268 * The promise rejects if the app is in development mode, or if there is an unexpected error or
269 * timeout communicating with the server.
270 */
271export async function fetchUpdateAsync(): Promise<UpdateFetchResult> {
272  if (!ExpoUpdates.fetchUpdateAsync) {
273    throw new UnavailabilityError('Updates', 'fetchUpdateAsync');
274  }
275  if (!ExpoUpdates?.nativeDebug && (__DEV__ || isUsingDeveloperTool)) {
276    throw new CodedError(
277      'ERR_UPDATES_DISABLED',
278      `You cannot fetch updates in development mode. ${manualUpdatesInstructions}`
279    );
280  }
281
282  const result = await ExpoUpdates.fetchUpdateAsync();
283  if (result.manifestString) {
284    result.manifest = JSON.parse(result.manifestString);
285    delete result.manifestString;
286  }
287
288  return result;
289}
290
291/**
292 * @hidden
293 */
294export function clearUpdateCacheExperimentalAsync(_sdkVersion?: string) {
295  console.warn(
296    "This method is no longer necessary. `expo-updates` now automatically deletes your app's old bundle files!"
297  );
298}
299
300let _emitter: EventEmitter | null;
301
302function _getEmitter(): EventEmitter {
303  if (!_emitter) {
304    _emitter = new EventEmitter();
305    DeviceEventEmitter.addListener('Expo.nativeUpdatesEvent', _emitEvent);
306  }
307  return _emitter;
308}
309
310function _emitEvent(params): void {
311  let newParams = { ...params };
312  if (typeof params === 'string') {
313    newParams = JSON.parse(params);
314  }
315  if (newParams.manifestString) {
316    newParams.manifest = JSON.parse(newParams.manifestString);
317    delete newParams.manifestString;
318  }
319
320  if (!_emitter) {
321    throw new Error(`EventEmitter must be initialized to use from its listener`);
322  }
323  _emitter.emit('Expo.updatesEvent', newParams);
324}
325
326/**
327 * Adds a callback to be invoked when updates-related events occur (such as upon the initial app
328 * load) due to auto-update settings chosen at build-time. See also the
329 * [`useUpdateEvents`](#useupdateeventslistener) React hook.
330 *
331 * @param listener A function that will be invoked with an [`UpdateEvent`](#updateevent) instance
332 * and should not return any value.
333 * @return An `EventSubscription` object on which you can call `remove()` to unsubscribe the
334 * listener.
335 */
336export function addListener(listener: (event: UpdateEvent) => void): EventSubscription {
337  const emitter = _getEmitter();
338  return emitter.addListener('Expo.updatesEvent', listener);
339}
340