Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/android-partial-custom-tabs.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions docs/docs/usage/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion packages/react-native-app-auth/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -661,7 +664,8 @@ private void authorizeWithConfiguration(
final Boolean useNonce,
final Boolean usePKCE,
final Map<String, String> additionalParametersMap,
final Boolean androidTrustedWebActivity) {
final Boolean androidTrustedWebActivity,
final Double androidCustomTabPartialHeightFraction) {

String scopesString = null;

Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/react-native-app-auth/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export type AuthConfiguration = BaseAuthConfiguration & {
| 'samsungCustomTab'
)[];
androidTrustedWebActivity?: boolean;
androidCustomTabPartialHeightFraction?: number;
iosPrefersEphemeralSession?: boolean;
};

Expand Down
14 changes: 14 additions & 0 deletions packages/react-native-app-auth/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -228,6 +239,7 @@ export const authorize = ({
iosCustomBrowser = null,
androidAllowCustomBrowsers = null,
androidTrustedWebActivity = false,
androidCustomTabPartialHeightFraction = null,
connectionTimeoutSeconds,
iosPrefersEphemeralSession = false,
}) => {
Expand All @@ -237,6 +249,7 @@ export const authorize = ({
validateHeaders(customHeaders);
validateAdditionalHeaders(additionalHeaders);
validateConnectionTimeoutSeconds(connectionTimeoutSeconds);
validateAndroidCustomTabPartialHeightFraction(androidCustomTabPartialHeightFraction);
// TODO: validateAdditionalParameters

const nativeMethodArguments = [
Expand All @@ -259,6 +272,7 @@ export const authorize = ({
nativeMethodArguments.push(customHeaders);
nativeMethodArguments.push(androidAllowCustomBrowsers);
nativeMethodArguments.push(androidTrustedWebActivity);
nativeMethodArguments.push(androidCustomTabPartialHeightFraction);
}

if (Platform.OS === 'ios') {
Expand Down
53 changes: 48 additions & 5 deletions packages/react-native-app-auth/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe('AppAuth', () => {
iosPrefersEphemeralSession: true,
androidAllowCustomBrowsers: ['chrome'],
androidTrustedWebActivity: false,
androidCustomTabPartialHeightFraction: null,
};

const registerConfig = {
Expand Down Expand Up @@ -758,7 +759,8 @@ describe('AppAuth', () => {
false,
config.customHeaders,
config.androidAllowCustomBrowsers,
config.androidTrustedWebActivity
config.androidTrustedWebActivity,
config.androidCustomTabPartialHeightFraction
);
});
});
Expand All @@ -782,7 +784,8 @@ describe('AppAuth', () => {
false,
config.customHeaders,
config.androidAllowCustomBrowsers,
config.androidTrustedWebActivity
config.androidTrustedWebActivity,
config.androidCustomTabPartialHeightFraction
);
});

Expand All @@ -804,7 +807,8 @@ describe('AppAuth', () => {
false,
config.customHeaders,
config.androidAllowCustomBrowsers,
config.androidTrustedWebActivity
config.androidTrustedWebActivity,
config.androidCustomTabPartialHeightFraction
);
});

Expand All @@ -826,7 +830,8 @@ describe('AppAuth', () => {
true,
config.customHeaders,
config.androidAllowCustomBrowsers,
config.androidTrustedWebActivity
config.androidTrustedWebActivity,
config.androidCustomTabPartialHeightFraction
);
});
});
Expand Down Expand Up @@ -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/
);
});
});
Expand Down