1import { UnavailabilityError } from 'expo-modules-core';
2import {
3  AppState,
4  AppStateStatus,
5  Linking,
6  Platform,
7  EmitterSubscription,
8  processColor,
9} from 'react-native';
10
11import ExponentWebBrowser from './ExpoWebBrowser';
12import {
13  RedirectEvent,
14  WebBrowserAuthSessionResult,
15  WebBrowserCompleteAuthSessionOptions,
16  WebBrowserCompleteAuthSessionResult,
17  WebBrowserCoolDownResult,
18  WebBrowserCustomTabsResults,
19  WebBrowserMayInitWithUrlResult,
20  WebBrowserOpenOptions,
21  WebBrowserRedirectResult,
22  WebBrowserResult,
23  WebBrowserResultType,
24  WebBrowserWarmUpResult,
25  WebBrowserWindowFeatures,
26  WebBrowserPresentationStyle,
27  AuthSessionOpenOptions,
28} from './WebBrowser.types';
29
30export {
31  WebBrowserAuthSessionResult,
32  WebBrowserCompleteAuthSessionOptions,
33  WebBrowserCompleteAuthSessionResult,
34  WebBrowserCoolDownResult,
35  WebBrowserCustomTabsResults,
36  WebBrowserMayInitWithUrlResult,
37  WebBrowserOpenOptions,
38  WebBrowserRedirectResult,
39  WebBrowserResult,
40  WebBrowserResultType,
41  WebBrowserWarmUpResult,
42  WebBrowserWindowFeatures,
43  WebBrowserPresentationStyle,
44  AuthSessionOpenOptions,
45};
46
47const emptyCustomTabsPackages: WebBrowserCustomTabsResults = {
48  defaultBrowserPackage: undefined,
49  preferredBrowserPackage: undefined,
50  browserPackages: [],
51  servicePackages: [],
52};
53
54// @needsAudit
55/**
56 * Returns a list of applications package names supporting Custom Tabs, Custom Tabs
57 * service, user chosen and preferred one. This may not be fully reliable, since it uses
58 * `PackageManager.getResolvingActivities` under the hood. (For example, some browsers might not be
59 * present in browserPackages list once another browser is set to default.)
60 *
61 * @return The promise which fulfils with [`WebBrowserCustomTabsResults`](#webbrowsercustomtabsresults) object.
62 * @platform android
63 */
64export async function getCustomTabsSupportingBrowsersAsync(): Promise<WebBrowserCustomTabsResults> {
65  if (!ExponentWebBrowser.getCustomTabsSupportingBrowsersAsync) {
66    throw new UnavailabilityError('WebBrowser', 'getCustomTabsSupportingBrowsersAsync');
67  }
68  if (Platform.OS !== 'android') {
69    return emptyCustomTabsPackages;
70  } else {
71    return await ExponentWebBrowser.getCustomTabsSupportingBrowsersAsync();
72  }
73}
74
75// @needsAudit
76/**
77 * This method calls `warmUp` method on [CustomTabsClient](https://developer.android.com/reference/android/support/customtabs/CustomTabsClient.html#warmup(long))
78 * for specified package.
79 *
80 * @param browserPackage Package of browser to be warmed up. If not set, preferred browser will be warmed.
81 *
82 * @return A promise which fulfils with `WebBrowserWarmUpResult` object.
83 * @platform android
84 */
85export async function warmUpAsync(browserPackage?: string): Promise<WebBrowserWarmUpResult> {
86  if (!ExponentWebBrowser.warmUpAsync) {
87    throw new UnavailabilityError('WebBrowser', 'warmUpAsync');
88  }
89  if (Platform.OS !== 'android') {
90    return {};
91  } else {
92    return await ExponentWebBrowser.warmUpAsync(browserPackage);
93  }
94}
95
96// @needsAudit
97/**
98 * This method initiates (if needed) [CustomTabsSession](https://developer.android.com/reference/android/support/customtabs/CustomTabsSession.html#maylaunchurl)
99 * and calls its `mayLaunchUrl` method for browser specified by the package.
100 *
101 * @param url The url of page that is likely to be loaded first when opening browser.
102 * @param browserPackage Package of browser to be informed. If not set, preferred
103 * browser will be used.
104 *
105 * @return A promise which fulfils with `WebBrowserMayInitWithUrlResult` object.
106 * @platform android
107 */
108export async function mayInitWithUrlAsync(
109  url: string,
110  browserPackage?: string
111): Promise<WebBrowserMayInitWithUrlResult> {
112  if (!ExponentWebBrowser.mayInitWithUrlAsync) {
113    throw new UnavailabilityError('WebBrowser', 'mayInitWithUrlAsync');
114  }
115  if (Platform.OS !== 'android') {
116    return {};
117  } else {
118    return await ExponentWebBrowser.mayInitWithUrlAsync(url, browserPackage);
119  }
120}
121
122// @needsAudit
123/**
124 * This methods removes all bindings to services created by [`warmUpAsync`](#webbrowserwarmupasyncbrowserpackage)
125 * or [`mayInitWithUrlAsync`](#webbrowsermayinitwithurlasyncurl-browserpackage). You should call
126 * this method once you don't need them to avoid potential memory leaks. However, those binding
127 * would be cleared once your application is destroyed, which might be sufficient in most cases.
128 *
129 * @param browserPackage Package of browser to be cooled. If not set, preferred browser will be used.
130 *
131 * @return The promise which fulfils with ` WebBrowserCoolDownResult` when cooling is performed, or
132 * an empty object when there was no connection to be dismissed.
133 * @platform android
134 */
135export async function coolDownAsync(browserPackage?: string): Promise<WebBrowserCoolDownResult> {
136  if (!ExponentWebBrowser.coolDownAsync) {
137    throw new UnavailabilityError('WebBrowser', 'coolDownAsync');
138  }
139  if (Platform.OS !== 'android') {
140    return {};
141  } else {
142    return await ExponentWebBrowser.coolDownAsync(browserPackage);
143  }
144}
145
146let browserLocked = false;
147
148// @needsAudit
149/**
150 * Opens the url with Safari in a modal on iOS using [`SFSafariViewController`](https://developer.apple.com/documentation/safariservices/sfsafariviewcontroller),
151 * and Chrome in a new [custom tab](https://developer.chrome.com/multidevice/android/customtabs)
152 * on Android. On iOS, the modal Safari will not share cookies with the system Safari. If you need
153 * this, use [`openAuthSessionAsync`](#webbrowseropenauthsessionasyncurl-redirecturl-browserparams).
154 *
155 * @param url The url to open in the web browser.
156 * @param browserParams A dictionary of key-value pairs.
157 *
158 * @return The promise behaves differently based on the platform.
159 * On Android promise resolves with `{type: 'opened'}` if we were able to open browser.
160 * On iOS:
161 * - If the user closed the web browser, the Promise resolves with `{ type: 'cancel' }`.
162 * - If the browser is closed using [`dismissBrowser`](#webbrowserdismissbrowser), the Promise resolves with `{ type: 'dismiss' }`.
163 */
164export async function openBrowserAsync(
165  url: string,
166  browserParams: WebBrowserOpenOptions = {}
167): Promise<WebBrowserResult> {
168  if (!ExponentWebBrowser.openBrowserAsync) {
169    throw new UnavailabilityError('WebBrowser', 'openBrowserAsync');
170  }
171
172  if (browserLocked) {
173    // Prevent multiple sessions from running at the same time, WebBrowser doesn't
174    // support it this makes the behavior predictable.
175    if (__DEV__) {
176      console.warn(
177        'Attempted to call WebBrowser.openBrowserAsync multiple times while already active. Only one WebBrowser controller can be active at any given time.'
178      );
179    }
180
181    return { type: WebBrowserResultType.LOCKED };
182  }
183  browserLocked = true;
184
185  let result: WebBrowserResult;
186  try {
187    result = await ExponentWebBrowser.openBrowserAsync(url, _processOptions(browserParams));
188  } finally {
189    // WebBrowser session complete, unset lock
190    browserLocked = false;
191  }
192
193  return result;
194}
195
196// @needsAudit
197/**
198 * Dismisses the presented web browser.
199 *
200 * @return The `void` on successful attempt, or throws error, if dismiss functionality is not avaiable.
201 * @platform ios
202 */
203export function dismissBrowser(): void {
204  if (!ExponentWebBrowser.dismissBrowser) {
205    throw new UnavailabilityError('WebBrowser', 'dismissBrowser');
206  }
207  ExponentWebBrowser.dismissBrowser();
208}
209
210// @needsAudit
211/**
212 * # On Android:
213 * This will be done using a "custom Chrome tabs" browser, [AppState](https://reactnative.dev/docs/appstate),
214 * and [Linking](./linking/) APIs.
215 *
216 * # On iOS:
217 * Opens the url with Safari in a modal using `ASWebAuthenticationSession`. The user will be asked
218 * whether to allow the app to authenticate using the given url.
219 * To handle redirection back to the mobile application, the redirect URI set in the authentication server
220 * has to use the protocol provided as the scheme in **app.json** [`expo.scheme`](./../config/app/#scheme).
221 * For example, `demo://` not `https://` protocol.
222 * Using `Linking.addEventListener` is not needed and can have side effects.
223 *
224 * # On web:
225 * > This API can only be used in a secure environment (`https`). You can use expo `start:web --https`
226 * to test this. Otherwise, an error with code [`ERR_WEB_BROWSER_CRYPTO`](#errwebbrowsercrypto) will be thrown.
227 * This will use the browser's [`window.open()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/open) API.
228 * - _Desktop_: This will create a new web popup window in the browser that can be closed later using `WebBrowser.maybeCompleteAuthSession()`.
229 * - _Mobile_: This will open a new tab in the browser which can be closed using `WebBrowser.maybeCompleteAuthSession()`.
230 *
231 * How this works on web:
232 * - A crypto state will be created for verifying the redirect.
233 *   - This means you need to run with `npx expo start --https`
234 * - The state will be added to the window's `localstorage`. This ensures that auth cannot complete
235 *   unless it's done from a page running with the same origin as it was started.
236 *   Ex: if `openAuthSessionAsync` is invoked on `https://localhost:19006`, then `maybeCompleteAuthSession`
237 *   must be invoked on a page hosted from the origin `https://localhost:19006`. Using a different
238 *   website, or even a different host like `https://128.0.0.*:19006` for example will not work.
239 * - A timer will be started to check for every 1000 milliseconds (1 second) to detect if the window
240 *   has been closed by the user. If this happens then a promise will resolve with `{ type: 'dismiss' }`.
241 *
242 * > On mobile web, Chrome and Safari will block any call to [`window.open()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/open)
243 * which takes too long to fire after a user interaction. This method must be invoked immediately
244 * after a user interaction. If the event is blocked, an error with code [`ERR_WEB_BROWSER_BLOCKED`](#errwebbrowserblocked) will be thrown.
245 *
246 * @param url The url to open in the web browser. This should be a login page.
247 * @param redirectUrl _Optional_ - The url to deep link back into your app.
248 * On web, this defaults to the output of [`Linking.createURL("")`](./linking/#linkingcreateurlpath-namedparameters).
249 * @param options _Optional_ - An object extending the [`WebBrowserOpenOptions`](#webbrowseropenoptions).
250 * If there is no native AuthSession implementation available (which is the case on Android)
251 * these params will be used in the browser polyfill. If there is a native AuthSession implementation,
252 * these params will be ignored.
253 *
254 * @return
255 * - If the user does not permit the application to authenticate with the given url, the Promise fulfills with `{ type: 'cancel' }` object.
256 * - If the user closed the web browser, the Promise fulfills with `{ type: 'cancel' }` object.
257 * - If the browser is closed using [`dismissBrowser`](#webbrowserdismissbrowser),
258 * the Promise fulfills with `{ type: 'dismiss' }` object.
259 */
260export async function openAuthSessionAsync(
261  url: string,
262  redirectUrl?: string | null,
263  options: AuthSessionOpenOptions = {}
264): Promise<WebBrowserAuthSessionResult> {
265  if (_authSessionIsNativelySupported()) {
266    if (!ExponentWebBrowser.openAuthSessionAsync) {
267      throw new UnavailabilityError('WebBrowser', 'openAuthSessionAsync');
268    }
269    if (['ios', 'web'].includes(Platform.OS)) {
270      return ExponentWebBrowser.openAuthSessionAsync(url, redirectUrl, _processOptions(options));
271    }
272    return ExponentWebBrowser.openAuthSessionAsync(url, redirectUrl);
273  } else {
274    return _openAuthSessionPolyfillAsync(url, redirectUrl, options);
275  }
276}
277
278// @docsMissing
279export function dismissAuthSession(): void {
280  if (_authSessionIsNativelySupported()) {
281    if (!ExponentWebBrowser.dismissAuthSession) {
282      throw new UnavailabilityError('WebBrowser', 'dismissAuthSession');
283    }
284    ExponentWebBrowser.dismissAuthSession();
285  } else {
286    if (!ExponentWebBrowser.dismissBrowser) {
287      throw new UnavailabilityError('WebBrowser', 'dismissAuthSession');
288    }
289    ExponentWebBrowser.dismissBrowser();
290  }
291}
292
293// @needsAudit
294/**
295 * Possibly completes an authentication session on web in a window popup. The method
296 * should be invoked on the page that the window redirects to.
297 *
298 * @param options
299 *
300 * @return Returns an object with message about why the redirect failed or succeeded:
301 *
302 * If `type` is set to `failed`, the reason depends on the message:
303 * - `Not supported on this platform`: If the platform doesn't support this method (iOS, Android).
304 * - `Cannot use expo-web-browser in a non-browser environment`: If the code was executed in an SSR
305 *   or node environment.
306 * - `No auth session is currently in progress`: (the cached state wasn't found in local storage).
307 *   This can happen if the window redirects to an origin (website) that is different to the initial
308 *   website origin. If this happens in development, it may be because the auth started on localhost
309 *   and finished on your computer port (Ex: `128.0.0.*`). This is controlled by the `redirectUrl`
310 *   and `returnUrl`.
311 * - `Current URL "<URL>" and original redirect URL "<URL>" do not match`: This can occur when the
312 *   redirect URL doesn't match what was initial defined as the `returnUrl`. You can skip this test
313 *   in development by passing `{ skipRedirectCheck: true }` to the function.
314 *
315 * If `type` is set to `success`, the parent window will attempt to close the child window immediately.
316 *
317 * If the error `ERR_WEB_BROWSER_REDIRECT` was thrown, it may mean that the parent window was
318 * reloaded before the auth was completed. In this case you'll need to close the child window manually.
319 *
320 * @platform web
321 */
322export function maybeCompleteAuthSession(
323  options: WebBrowserCompleteAuthSessionOptions = {}
324): WebBrowserCompleteAuthSessionResult {
325  if (ExponentWebBrowser.maybeCompleteAuthSession) {
326    return ExponentWebBrowser.maybeCompleteAuthSession(options);
327  }
328  return { type: 'failed', message: 'Not supported on this platform' };
329}
330
331function _processOptions(options: WebBrowserOpenOptions) {
332  return {
333    ...options,
334    controlsColor: processColor(options.controlsColor),
335    toolbarColor: processColor(options.toolbarColor),
336    secondaryToolbarColor: processColor(options.secondaryToolbarColor),
337  };
338}
339
340/* iOS <= 10 and Android polyfill for SFAuthenticationSession flow */
341
342function _authSessionIsNativelySupported(): boolean {
343  if (Platform.OS === 'android') {
344    return false;
345  } else if (Platform.OS === 'web') {
346    return true;
347  }
348
349  const versionNumber = parseInt(String(Platform.Version), 10);
350  return versionNumber >= 11;
351}
352
353let _redirectSubscription: EmitterSubscription | null = null;
354
355/*
356 * openBrowserAsync on Android doesn't wait until closed, so we need to polyfill
357 * it with AppState
358 */
359
360// Store the `resolve` function from a Promise to fire when the AppState
361// returns to active
362let _onWebBrowserCloseAndroid: null | (() => void) = null;
363
364// If the initial AppState.currentState is null, we assume that the first call to
365// AppState#change event is not actually triggered by a real change,
366// is triggered instead by the bridge capturing the current state
367// (https://reactnative.dev/docs/appstate#basic-usage)
368let _isAppStateAvailable: boolean = AppState.currentState !== null;
369function _onAppStateChangeAndroid(state: AppStateStatus) {
370  if (!_isAppStateAvailable) {
371    _isAppStateAvailable = true;
372    return;
373  }
374
375  if (state === 'active' && _onWebBrowserCloseAndroid) {
376    _onWebBrowserCloseAndroid();
377  }
378}
379
380async function _openBrowserAndWaitAndroidAsync(
381  startUrl: string,
382  browserParams: WebBrowserOpenOptions = {}
383): Promise<WebBrowserResult> {
384  const appStateChangedToActive = new Promise<void>((resolve) => {
385    _onWebBrowserCloseAndroid = resolve;
386  });
387  const stateChangeSubscription = AppState.addEventListener('change', _onAppStateChangeAndroid);
388
389  let result: WebBrowserResult = { type: WebBrowserResultType.CANCEL };
390  let type: string | null = null;
391
392  try {
393    ({ type } = await openBrowserAsync(startUrl, browserParams));
394  } catch (e) {
395    stateChangeSubscription.remove();
396    _onWebBrowserCloseAndroid = null;
397    throw e;
398  }
399
400  if (type === 'opened') {
401    await appStateChangedToActive;
402    result = { type: WebBrowserResultType.DISMISS };
403  }
404
405  stateChangeSubscription.remove();
406  _onWebBrowserCloseAndroid = null;
407  return result;
408}
409
410async function _openAuthSessionPolyfillAsync(
411  startUrl: string,
412  returnUrl: string | null | undefined,
413  browserParams: WebBrowserOpenOptions = {}
414): Promise<WebBrowserAuthSessionResult> {
415  if (_redirectSubscription) {
416    throw new Error(
417      `The WebBrowser's auth session is in an invalid state with a redirect handler set when it should not be`
418    );
419  }
420
421  if (_onWebBrowserCloseAndroid) {
422    throw new Error(`WebBrowser is already open, only one can be open at a time`);
423  }
424
425  try {
426    if (Platform.OS === 'android') {
427      return await Promise.race([
428        _openBrowserAndWaitAndroidAsync(startUrl, browserParams),
429        _waitForRedirectAsync(returnUrl),
430      ]);
431    } else {
432      return await Promise.race([
433        openBrowserAsync(startUrl, browserParams),
434        _waitForRedirectAsync(returnUrl),
435      ]);
436    }
437  } finally {
438    // We can't dismiss the browser on Android, only call this when it's available.
439    // Users on Android need to manually press the 'x' button in Chrome Custom Tabs, sadly.
440    if (ExponentWebBrowser.dismissBrowser) {
441      ExponentWebBrowser.dismissBrowser();
442    }
443
444    _stopWaitingForRedirect();
445  }
446}
447
448function _stopWaitingForRedirect() {
449  if (!_redirectSubscription) {
450    throw new Error(
451      `The WebBrowser auth session is in an invalid state with no redirect handler when one should be set`
452    );
453  }
454
455  _redirectSubscription.remove();
456  _redirectSubscription = null;
457}
458
459function _waitForRedirectAsync(
460  returnUrl: string | null | undefined
461): Promise<WebBrowserRedirectResult> {
462  // Note that this Promise never resolves when `returnUrl` is nullish
463  return new Promise((resolve) => {
464    const redirectHandler = (event: RedirectEvent) => {
465      if (returnUrl && event.url.startsWith(returnUrl)) {
466        resolve({ url: event.url, type: 'success' });
467      }
468    };
469
470    _redirectSubscription = Linking.addEventListener('url', redirectHandler);
471  });
472}
473