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