diff --git a/.changeset/android-partial-custom-tabs.md b/.changeset/android-partial-custom-tabs.md new file mode 100644 index 000000000..adef6603c --- /dev/null +++ b/.changeset/android-partial-custom-tabs.md @@ -0,0 +1,5 @@ +--- +'react-native-app-auth': minor +--- + +Android: opt-in Partial Custom Tab (bottom-sheet) for the authorization flow via the new `androidCustomTabPartialHeightFraction` option. When set to a fraction in `(0, 1]`, Chrome 107+ renders the Custom Tab as a user-resizable bottom sheet at that fraction of the screen height (a common choice is `0.85`), keeping the app visible behind the sheet and matching the modal feel of iOS's `ASWebAuthenticationSession`. Older Chrome silently ignores the extra and falls back to the full-screen Custom Tab. Bumps `androidx.browser:browser` from `1.4.0` to `1.5.0` for the `setInitialActivityHeightPx` API. diff --git a/docs/docs/usage/config.md b/docs/docs/usage/config.md index 47570aef6..38cb6d4c3 100644 --- a/docs/docs/usage/config.md +++ b/docs/docs/usage/config.md @@ -50,4 +50,11 @@ See specific example [configurations for your provider](/docs/category/providers - **iosPrefersEphemeralSession** - (`boolean`) (default: `false`) _IOS_ indicates whether the session should ask the browser for a private authentication session. - **androidAllowCustomBrowsers** - (`string[]`) (default: undefined) _ANDROID_ override the used browser for authorization. If no value is provided, all browsers are allowed. - **androidTrustedWebActivity** - (`boolean`) (default: `false`) _ANDROID_ Use [`EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY`](https://developer.chrome.com/docs/android/trusted-web-activity/) when opening web view. +- **androidCustomTabPartialHeightFraction** - (`number`) (default: undefined) _ANDROID_ render the Chrome [Partial Custom Tab](https://developer.chrome.com/docs/android/custom-tabs/guide-partial-custom-tabs) as a bottom-sheet at this fraction (greater than 0, at most 1) of the screen height. The activity stays user-resizable to full screen via the drag handle. A common choice is `0.85`. Notes: + - Requires Chrome 107+; older Chrome silently ignores the extra and falls back to a full-screen Custom Tab. + - For the bottom-sheet to render on the **first** invocation rather than only on subsequent ones, call [`prefetchConfiguration`](/docs/prefetch) when the screen mounts so Chrome's `CustomTabsService` is already bound by the time the user taps. + - Chrome enforces a 50% minimum height — fractions below `0.5` are silently clamped by Chrome to `0.5`. + - Chrome only renders the Partial Custom Tab in **portrait** orientation; in landscape Chrome falls back to a full-screen Custom Tab. + - Non-Chrome browsers selected via `androidAllowCustomBrowsers` (e.g. Firefox) do not implement the Partial Custom Tab extra and fall back to a full-screen Custom Tab. + - The bumped `androidx.browser:browser:1.5.0` dependency requires `compileSdkVersion` **33+**. Consumers that pin `compileSdkVersion` lower than 33 must raise it. - **connectionTimeoutSeconds** - (`number`) configure the request timeout interval in seconds. This must be a positive number. The default values are 60 seconds on iOS and 15 seconds on Android. diff --git a/packages/react-native-app-auth/android/build.gradle b/packages/react-native-app-auth/android/build.gradle index 9aab56516..029560d60 100644 --- a/packages/react-native-app-auth/android/build.gradle +++ b/packages/react-native-app-auth/android/build.gradle @@ -62,5 +62,5 @@ dependencies { //noinspection GradleDynamicVersion implementation 'com.facebook.react:react-native:+' // From node_modules implementation 'net.openid:appauth:0.11.1' - implementation 'androidx.browser:browser:1.4.0' + implementation 'androidx.browser:browser:1.5.0' } diff --git a/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java b/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java index 33ca68120..5712d8196 100644 --- a/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java +++ b/packages/react-native-app-auth/android/src/main/java/com/rnappauth/RNAppAuthModule.java @@ -244,6 +244,7 @@ public void authorize( final ReadableMap customHeaders, final ReadableArray androidAllowCustomBrowsers, final boolean androidTrustedWebActivity, + final Double androidCustomTabPartialHeightFraction, final Promise promise) { this.parseHeaderMap(customHeaders); final ConnectionBuilder builder = createConnectionBuilder(dangerouslyAllowInsecureHttpRequests, @@ -278,7 +279,8 @@ public void authorize( useNonce, usePKCE, additionalParametersMap, - androidTrustedWebActivity); + androidTrustedWebActivity, + androidCustomTabPartialHeightFraction); } catch (ActivityNotFoundException e) { promise.reject("browser_not_found", e.getMessage()); } catch (Exception e) { @@ -309,7 +311,8 @@ public void onFetchConfigurationCompleted( useNonce, usePKCE, additionalParametersMap, - androidTrustedWebActivity); + androidTrustedWebActivity, + androidCustomTabPartialHeightFraction); } catch (ActivityNotFoundException e) { promise.reject("browser_not_found", e.getMessage()); } catch (Exception e) { @@ -661,7 +664,8 @@ private void authorizeWithConfiguration( final Boolean useNonce, final Boolean usePKCE, final Map additionalParametersMap, - final Boolean androidTrustedWebActivity) { + final Boolean androidTrustedWebActivity, + final Double androidCustomTabPartialHeightFraction) { String scopesString = null; @@ -736,6 +740,17 @@ private void authorizeWithConfiguration( AuthorizationService authService = new AuthorizationService(context, appAuthConfiguration); CustomTabsIntent.Builder intentBuilder = authService.createCustomTabsIntentBuilder(); + + // Opt-in Partial Custom Tab (bottom-sheet) when caller supplies a height fraction + // and the device runs Chrome 107+ with androidx.browser 1.5.0+ — otherwise Chrome + // silently ignores the extra and falls back to a full-screen Custom Tab. Skipped for + // Trusted Web Activities (which intentionally render full-screen). + if (!androidTrustedWebActivity && androidCustomTabPartialHeightFraction != null) { + int displayHeightPx = currentActivity.getResources().getDisplayMetrics().heightPixels; + int initialHeightPx = (int) (displayHeightPx * androidCustomTabPartialHeightFraction); + intentBuilder.setInitialActivityHeightPx(initialHeightPx, CustomTabsIntent.ACTIVITY_HEIGHT_ADJUSTABLE); + } + CustomTabsIntent customTabsIntent = intentBuilder.build(); if (androidTrustedWebActivity) { diff --git a/packages/react-native-app-auth/index.d.ts b/packages/react-native-app-auth/index.d.ts index e9ff392a1..50f7e3bdd 100644 --- a/packages/react-native-app-auth/index.d.ts +++ b/packages/react-native-app-auth/index.d.ts @@ -89,6 +89,7 @@ export type AuthConfiguration = BaseAuthConfiguration & { | 'samsungCustomTab' )[]; androidTrustedWebActivity?: boolean; + androidCustomTabPartialHeightFraction?: number; iosPrefersEphemeralSession?: boolean; }; diff --git a/packages/react-native-app-auth/index.js b/packages/react-native-app-auth/index.js index 8ebc7e770..14f63a07b 100644 --- a/packages/react-native-app-auth/index.js +++ b/packages/react-native-app-auth/index.js @@ -101,6 +101,17 @@ const validateConnectionTimeoutSeconds = timeout => { invariant(typeof timeout === 'number', 'Config error: connectionTimeoutSeconds must be a number'); }; +const validateAndroidCustomTabPartialHeightFraction = fraction => { + if (fraction == null) { + return; + } + + invariant( + typeof fraction === 'number' && fraction > 0 && fraction <= 1, + 'Config error: androidCustomTabPartialHeightFraction must be a number greater than 0 and at most 1' + ); +}; + export const SECOND_IN_MS = 1000; export const DEFAULT_TIMEOUT_IOS = 60; export const DEFAULT_TIMEOUT_ANDROID = 15; @@ -228,6 +239,7 @@ export const authorize = ({ iosCustomBrowser = null, androidAllowCustomBrowsers = null, androidTrustedWebActivity = false, + androidCustomTabPartialHeightFraction = null, connectionTimeoutSeconds, iosPrefersEphemeralSession = false, }) => { @@ -237,6 +249,7 @@ export const authorize = ({ validateHeaders(customHeaders); validateAdditionalHeaders(additionalHeaders); validateConnectionTimeoutSeconds(connectionTimeoutSeconds); + validateAndroidCustomTabPartialHeightFraction(androidCustomTabPartialHeightFraction); // TODO: validateAdditionalParameters const nativeMethodArguments = [ @@ -259,6 +272,7 @@ export const authorize = ({ nativeMethodArguments.push(customHeaders); nativeMethodArguments.push(androidAllowCustomBrowsers); nativeMethodArguments.push(androidTrustedWebActivity); + nativeMethodArguments.push(androidCustomTabPartialHeightFraction); } if (Platform.OS === 'ios') { diff --git a/packages/react-native-app-auth/index.spec.js b/packages/react-native-app-auth/index.spec.js index bc2c278b6..94e368125 100644 --- a/packages/react-native-app-auth/index.spec.js +++ b/packages/react-native-app-auth/index.spec.js @@ -69,6 +69,7 @@ describe('AppAuth', () => { iosPrefersEphemeralSession: true, androidAllowCustomBrowsers: ['chrome'], androidTrustedWebActivity: false, + androidCustomTabPartialHeightFraction: null, }; const registerConfig = { @@ -758,7 +759,8 @@ describe('AppAuth', () => { false, config.customHeaders, config.androidAllowCustomBrowsers, - config.androidTrustedWebActivity + config.androidTrustedWebActivity, + config.androidCustomTabPartialHeightFraction ); }); }); @@ -782,7 +784,8 @@ describe('AppAuth', () => { false, config.customHeaders, config.androidAllowCustomBrowsers, - config.androidTrustedWebActivity + config.androidTrustedWebActivity, + config.androidCustomTabPartialHeightFraction ); }); @@ -804,7 +807,8 @@ describe('AppAuth', () => { false, config.customHeaders, config.androidAllowCustomBrowsers, - config.androidTrustedWebActivity + config.androidTrustedWebActivity, + config.androidCustomTabPartialHeightFraction ); }); @@ -826,7 +830,8 @@ describe('AppAuth', () => { true, config.customHeaders, config.androidAllowCustomBrowsers, - config.androidTrustedWebActivity + config.androidTrustedWebActivity, + config.androidCustomTabPartialHeightFraction ); }); }); @@ -857,7 +862,45 @@ describe('AppAuth', () => { false, customHeaders, config.androidAllowCustomBrowsers, - config.androidTrustedWebActivity + config.androidTrustedWebActivity, + config.androidCustomTabPartialHeightFraction + ); + }); + }); + describe('androidCustomTabPartialHeightFraction parameter', () => { + it('forwards a valid fraction to the native bridge', () => { + authorize({ ...config, androidCustomTabPartialHeightFraction: 0.85 }); + const lastArg = mockAuthorize.mock.calls[0][mockAuthorize.mock.calls[0].length - 1]; + expect(lastArg).toBe(0.85); + }); + + it('forwards null when omitted', () => { + authorize(config); + const lastArg = mockAuthorize.mock.calls[0][mockAuthorize.mock.calls[0].length - 1]; + expect(lastArg).toBeNull(); + }); + + it('throws when the fraction is not a number', () => { + expect(() => { + authorize({ ...config, androidCustomTabPartialHeightFraction: 'half' }); + }).toThrow( + /androidCustomTabPartialHeightFraction must be a number greater than 0 and at most 1/ + ); + }); + + it('throws when the fraction is 0', () => { + expect(() => { + authorize({ ...config, androidCustomTabPartialHeightFraction: 0 }); + }).toThrow( + /androidCustomTabPartialHeightFraction must be a number greater than 0 and at most 1/ + ); + }); + + it('throws when the fraction is greater than 1', () => { + expect(() => { + authorize({ ...config, androidCustomTabPartialHeightFraction: 1.5 }); + }).toThrow( + /androidCustomTabPartialHeightFraction must be a number greater than 0 and at most 1/ ); }); });