From f15251e3c0debd2c2c9b1bb51e289dfc91e8b021 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 16 Apr 2026 23:50:05 +0530 Subject: [PATCH 1/5] feat: Implement Partial Custom Tabs with customizable dimensions and background interaction --- auth0/build.gradle | 2 +- .../android/provider/CustomTabsOptions.java | 178 ++++++++++++- .../provider/CustomTabsOptionsTest.java | 238 ++++++++++++++++++ 3 files changed, 415 insertions(+), 3 deletions(-) diff --git a/auth0/build.gradle b/auth0/build.gradle index e3b5cae26..edb14a9a3 100644 --- a/auth0/build.gradle +++ b/auth0/build.gradle @@ -87,7 +87,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.appcompat:appcompat:1.6.0' - implementation 'androidx.browser:browser:1.4.0' + implementation 'androidx.browser:browser:1.8.0' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java index 765eed204..627a5d95b 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -9,6 +9,7 @@ import android.os.Parcelable; import androidx.annotation.ColorRes; +import androidx.annotation.Dimension; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.browser.customtabs.CustomTabColorSchemeParams; @@ -21,6 +22,8 @@ import java.util.List; +import android.util.DisplayMetrics; + /** * Holder for Custom Tabs customization options. Use {@link CustomTabsOptions#newBuilder()} to begin. */ @@ -34,11 +37,38 @@ public class CustomTabsOptions implements Parcelable { @Nullable private final List disabledCustomTabsPackages; - private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNull BrowserPicker browserPicker, @Nullable List disabledCustomTabsPackages) { + // Partial Custom Tabs - Bottom Sheet + private final int initialHeight; + private final int activityHeightResizeBehavior; + private final int toolbarCornerRadius; + + // Partial Custom Tabs - Side Sheet + private final int initialWidth; + private final int sideSheetBreakpoint; + + // Partial Custom Tabs - Background Interaction + private final boolean backgroundInteractionEnabled; + + private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNull BrowserPicker browserPicker, + @Nullable List disabledCustomTabsPackages, + int initialHeight, int activityHeightResizeBehavior, int toolbarCornerRadius, + int initialWidth, int sideSheetBreakpoint, + boolean backgroundInteractionEnabled) { this.showTitle = showTitle; this.toolbarColor = toolbarColor; this.browserPicker = browserPicker; this.disabledCustomTabsPackages = disabledCustomTabsPackages; + this.initialHeight = initialHeight; + this.activityHeightResizeBehavior = activityHeightResizeBehavior; + this.toolbarCornerRadius = toolbarCornerRadius; + this.initialWidth = initialWidth; + this.sideSheetBreakpoint = sideSheetBreakpoint; + this.backgroundInteractionEnabled = backgroundInteractionEnabled; + } + + private static int dpToPx(@NonNull Context context, int dp) { + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + return Math.round(dp * metrics.density); } @Nullable @@ -88,6 +118,28 @@ Intent toIntent(@NonNull Context context, @Nullable CustomTabsSession session) { .setToolbarColor(ContextCompat.getColor(context, toolbarColor)); builder.setDefaultColorSchemeParams(colorBuilder.build()); } + + // Partial Custom Tabs - Bottom Sheet + if (initialHeight > 0) { + builder.setInitialActivityHeightPx(dpToPx(context, initialHeight), activityHeightResizeBehavior); + } + if (toolbarCornerRadius > 0) { + builder.setToolbarCornerRadiusDp(toolbarCornerRadius); + } + + // Partial Custom Tabs - Side Sheet + if (initialWidth > 0) { + builder.setInitialActivityWidthPx(dpToPx(context, initialWidth)); + } + if (sideSheetBreakpoint > 0) { + builder.setActivitySideSheetBreakpointDp(sideSheetBreakpoint); + } + + // Partial Custom Tabs - Background Interaction + if (backgroundInteractionEnabled) { + builder.setBackgroundInteractionEnabled(true); + } + return builder.build().intent; } @@ -108,6 +160,12 @@ protected CustomTabsOptions(@NonNull Parcel in) { toolbarColor = in.readInt(); browserPicker = in.readParcelable(BrowserPicker.class.getClassLoader()); disabledCustomTabsPackages = in.createStringArrayList(); + initialHeight = in.readInt(); + activityHeightResizeBehavior = in.readInt(); + toolbarCornerRadius = in.readInt(); + initialWidth = in.readInt(); + sideSheetBreakpoint = in.readInt(); + backgroundInteractionEnabled = in.readByte() != 0; } @Override @@ -116,6 +174,12 @@ public void writeToParcel(@NonNull Parcel dest, int flags) { dest.writeInt(toolbarColor); dest.writeParcelable(browserPicker, flags); dest.writeStringList(disabledCustomTabsPackages); + dest.writeInt(initialHeight); + dest.writeInt(activityHeightResizeBehavior); + dest.writeInt(toolbarCornerRadius); + dest.writeInt(initialWidth); + dest.writeInt(sideSheetBreakpoint); + dest.writeByte((byte) (backgroundInteractionEnabled ? 1 : 0)); } @Override @@ -147,11 +211,24 @@ public static class Builder { @Nullable private List disabledCustomTabsPackages; + private int initialHeight; + private int activityHeightResizeBehavior; + private int toolbarCornerRadius; + private int initialWidth; + private int sideSheetBreakpoint; + private boolean backgroundInteractionEnabled; + Builder() { this.showTitle = false; this.toolbarColor = 0; this.browserPicker = BrowserPicker.newBuilder().build(); this.disabledCustomTabsPackages = null; + this.initialHeight = 0; + this.activityHeightResizeBehavior = CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT; + this.toolbarCornerRadius = 0; + this.initialWidth = 0; + this.sideSheetBreakpoint = 0; + this.backgroundInteractionEnabled = false; } /** @@ -212,6 +289,101 @@ public Builder withDisabledCustomTabsPackages(List disabledCustomTabsPac return this; } + /** + * Sets the initial height for the Custom Tab to display as a bottom sheet. + * When set, the Custom Tab will appear as a bottom sheet instead of full screen. + * Pass the size in dp; it will be converted to pixels internally. + * The minimum height enforced by Chrome is 50% of the screen; values below this are auto-adjusted. + * Falls back to full screen on browsers that don't support Partial Custom Tabs (requires Chrome 107+). + * By default, the bottom sheet is resizable by the user. Use {@link #withResizableHeight(boolean)} + * to lock the height. + * + * @param height the initial bottom sheet height in dp. + * @return this same builder instance. + */ + @NonNull + public Builder withInitialHeight(@Dimension(unit = Dimension.DP) int height) { + this.initialHeight = height; + return this; + } + + /** + * Sets whether the user can resize the Partial Custom Tab by dragging. + * For bottom sheets, this controls whether the user can drag the toolbar handle to + * expand or collapse the sheet. For side sheets, this controls whether the sheet + * can be resized. By default, the Partial Custom Tab is resizable. + * Pass {@code false} to lock the size. Only takes effect when + * {@link #withInitialHeight(int)} is also set. + * + * @param resizable whether the Partial Custom Tab should be resizable. + * @return this same builder instance. + */ + @NonNull + public Builder withResizable(boolean resizable) { + this.activityHeightResizeBehavior = resizable + ? CustomTabsIntent.ACTIVITY_HEIGHT_ADJUSTABLE + : CustomTabsIntent.ACTIVITY_HEIGHT_FIXED; + return this; + } + + /** + * Sets the toolbar's top corner radii in dp. Only takes effect when the Custom Tab is + * displayed as a bottom sheet (i.e., when {@link #withInitialHeight(int)} is also set). + * Pass the size in dp. + * + * @param cornerRadius the toolbar's top corner radius in dp. + * @return this same builder instance. + */ + @NonNull + public Builder withToolbarCornerRadius(@Dimension(unit = Dimension.DP) int cornerRadius) { + this.toolbarCornerRadius = cornerRadius; + return this; + } + + /** + * Sets the initial width for the Custom Tab to display as a side sheet on larger screens. + * The Custom Tab will behave as a side sheet if the screen's width is bigger than the + * breakpoint value set by {@link #withSideSheetBreakpoint(int)}. + * Pass the size in dp; it will be converted to pixels internally. + * Falls back to bottom sheet or full screen on unsupported browsers. + * + * @param width the initial side sheet width in dp. + * @return this same builder instance. + */ + @NonNull + public Builder withInitialWidth(@Dimension(unit = Dimension.DP) int width) { + this.initialWidth = width; + return this; + } + + /** + * Sets the breakpoint in dp to switch between bottom sheet and side sheet mode. + * If the screen's width is bigger than this value, the Custom Tab will behave as a side sheet; + * otherwise it will behave as a bottom sheet. The browser default is typically 840dp. + * Pass the size in dp. + * + * @param breakpoint the breakpoint in dp. + * @return this same builder instance. + */ + @NonNull + public Builder withSideSheetBreakpoint(@Dimension(unit = Dimension.DP) int breakpoint) { + this.sideSheetBreakpoint = breakpoint; + return this; + } + + /** + * Enables or disables interaction with the background app when a Partial Custom Tab is displayed. + * By default, background interaction is disabled. + * + * @param enabled whether to enable interaction with the app behind the partial tab. + * @return this same builder instance. + */ + @NonNull + public Builder withBackgroundInteractionEnabled(boolean enabled) { + this.backgroundInteractionEnabled = enabled; + return this; + } + /** * Create a new CustomTabsOptions instance with the customization settings. * @@ -219,7 +391,9 @@ public Builder withDisabledCustomTabsPackages(List disabledCustomTabsPac */ @NonNull public CustomTabsOptions build() { - return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, disabledCustomTabsPackages); + return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, disabledCustomTabsPackages, + initialHeight, activityHeightResizeBehavior, toolbarCornerRadius, + initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled); } } diff --git a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java index c4f03efb4..ca1f72701 100644 --- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java @@ -87,6 +87,13 @@ public void shouldHaveDefaultValues() { assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE, CustomTabsIntent.NO_TITLE), is(CustomTabsIntent.NO_TITLE)); assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_SHARE_STATE, CustomTabsIntent.SHARE_STATE_OFF), is(CustomTabsIntent.SHARE_STATE_OFF)); + // Should not have partial custom tab extras when not set + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(false)); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(false)); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX), is(false)); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP), is(false)); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(false)); + Parcel parcel = Parcel.obtain(); options.writeToParcel(parcel, 0); parcel.setDataPosition(0); @@ -100,6 +107,13 @@ public void shouldHaveDefaultValues() { assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR, 0), is(0)); assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE, CustomTabsIntent.NO_TITLE), is(CustomTabsIntent.NO_TITLE)); assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_SHARE_STATE, CustomTabsIntent.SHARE_STATE_OFF), is(CustomTabsIntent.SHARE_STATE_OFF)); + + // Parceled intent should also not have partial custom tab extras + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(false)); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(false)); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX), is(false)); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP), is(false)); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(false)); } @Test @@ -224,4 +238,228 @@ public void shouldSetDisabledCustomTabPackages() { int resolvedColor = ContextCompat.getColor(activity, android.R.color.black); assertThat(intentWithToolbarExtra.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR, 0), is(resolvedColor)); } + + // --- Partial Custom Tabs: Bottom Sheet --- + // Note: Robolectric uses density=1.0 (mdpi) by default, so dp values equal px values in tests. + + @Test + public void shouldSetInitialActivityHeight() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(800)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(true)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(800)); + } + + @Test + public void shouldSetInitialActivityHeightWithFixedResize() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(600) + .withResizable(false) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(600)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_FIXED)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(600)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_FIXED)); + } + + @Test + public void shouldSetToolbarCornerRadius() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .withToolbarCornerRadius(16) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(true)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); + } + + // --- Partial Custom Tabs: Side Sheet --- + // Note: withInitialWidth accepts dp; converted to px internally (1:1 in Robolectric mdpi). + + @Test + public void shouldSetInitialActivityWidth() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(600) + .withInitialWidth(400) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX, 0), is(400)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX), is(true)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX, 0), is(400)); + } + + @Test + public void shouldSetSideSheetBreakpoint() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(600) + .withInitialWidth(400) + .withSideSheetBreakpoint(840) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP, 0), is(840)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP), is(true)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP, 0), is(840)); + } + + // --- Partial Custom Tabs: Background Interaction --- + + @Test + public void shouldSetBackgroundInteractionEnabled() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .withBackgroundInteractionEnabled(true) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + // When background interaction is enabled, EXTRA_DISABLE_BACKGROUND_INTERACTION should be present and false + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + } + + @Test + public void shouldNotSetBackgroundInteractionWhenDisabled() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .withBackgroundInteractionEnabled(false) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(false)); + } + + // --- Combined Partial Custom Tabs --- + + @Test + public void shouldSetAllPartialCustomTabOptions() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withToolbarColor(android.R.color.black) + .showTitle(true) + .withInitialHeight(800) + .withResizable(false) + .withToolbarCornerRadius(16) + .withInitialWidth(500) + .withSideSheetBreakpoint(840) + .withBackgroundInteractionEnabled(true) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + + // Existing options + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE, CustomTabsIntent.NO_TITLE), is(CustomTabsIntent.SHOW_PAGE_TITLE)); + + // Bottom sheet + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(800)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_FIXED)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); + + // Side sheet + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX, 0), is(500)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP, 0), is(840)); + + // Background interaction + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + + // Verify parceling preserves everything + Parcel parcel = Parcel.obtain(); + options.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); + Intent parceledIntent = parceledOptions.toIntent(context, null); + + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR), is(true)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX, 0), is(800)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_FIXED)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_WIDTH_PX, 0), is(500)); + assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_SIDE_SHEET_BREAKPOINT_DP, 0), is(840)); + assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + } + + @Test + public void shouldNotSetPartialOptionsWhenDisabledBrowser() { + Activity activity = spy(Robolectric.setupActivity(Activity.class)); + BrowserPickerTest.setupBrowserContext(activity, Collections.singletonList("com.auth0.browser"), null, null); + BrowserPicker browserPicker = BrowserPicker.newBuilder().build(); + + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withBrowserPicker(browserPicker) + .withDisabledCustomTabsPackages(List.of("com.auth0.browser")) + .withInitialHeight(800) + .withToolbarCornerRadius(16) + .build(); + assertThat(options, is(notNullValue())); + + Intent intentNoExtras = options.toIntent(activity, null); + assertThat(intentNoExtras, is(notNullValue())); + assertThat(intentNoExtras.getExtras(), is(nullValue())); + assertEquals(intentNoExtras.getAction(), "android.intent.action.VIEW"); + } } \ No newline at end of file From e458f71e3920d6a237ccb337b87ae0340caa5fb7 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 17 Apr 2026 14:52:02 +0530 Subject: [PATCH 2/5] addressed pr comments --- .../android/provider/CustomTabsOptions.java | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java index 627a5d95b..7835402f4 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -66,11 +66,6 @@ private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNul this.backgroundInteractionEnabled = backgroundInteractionEnabled; } - private static int dpToPx(@NonNull Context context, int dp) { - final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); - return Math.round(dp * metrics.density); - } - @Nullable String getPreferredPackage(@NonNull PackageManager pm) { return browserPicker.getBestBrowserPackage(pm); @@ -342,8 +337,11 @@ public Builder withToolbarCornerRadius(@Dimension(unit = Dimension.DP) int corne /** * Sets the initial width for the Custom Tab to display as a side sheet on larger screens. - * The Custom Tab will behave as a side sheet if the screen's width is bigger than the - * breakpoint value set by {@link #withSideSheetBreakpoint(int)}. + * The Custom Tab will behave as a side sheet only if the screen's width is bigger than + * the breakpoint value set by {@link #withSideSheetBreakpoint(int)}. If no breakpoint is + * explicitly set, the browser's default breakpoint (typically 840dp in Chrome) is used, + * so smaller-width devices will continue to render as a bottom sheet or full screen + * rather than as a side sheet. * Pass the size in dp; it will be converted to pixels internally. * Falls back to bottom sheet or full screen on unsupported browsers. * @@ -359,8 +357,12 @@ public Builder withInitialWidth(@Dimension(unit = Dimension.DP) int width) { /** * Sets the breakpoint in dp to switch between bottom sheet and side sheet mode. * If the screen's width is bigger than this value, the Custom Tab will behave as a side sheet; - * otherwise it will behave as a bottom sheet. The browser default is typically 840dp. - * Pass the size in dp. + * otherwise it will behave as a bottom sheet. + *

+ * When this method is not called (or the value is left at the default {@code 0}), the + * breakpoint is not overridden and the browser's built-in default (typically + * {@code 840dp} in Chrome) is applied. This means devices with a screen width smaller + * than the browser default will still render as a bottom sheet, not a side sheet. * * @param breakpoint the breakpoint in dp. * @return this same builder instance. @@ -396,5 +398,9 @@ public CustomTabsOptions build() { initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled); } } + private int dpToPx(@NonNull Context context, int dp) { + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + return Math.round(dp * metrics.density); + } } From 964a4e4615dd6e104caa703d1d55a8f67f5a16f6 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 17 Apr 2026 15:04:27 +0530 Subject: [PATCH 3/5] fix: Update CustomTabsOptions to use Collections.singletonList for disabled packages and add test for adjustable height --- .../android/provider/CustomTabsOptions.java | 10 ++++---- .../provider/CustomTabsOptionsTest.java | 24 +++++++++++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java index 7835402f4..83a818a94 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -7,6 +7,7 @@ import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; +import android.util.DisplayMetrics; import androidx.annotation.ColorRes; import androidx.annotation.Dimension; @@ -22,8 +23,6 @@ import java.util.List; -import android.util.DisplayMetrics; - /** * Holder for Custom Tabs customization options. Use {@link CustomTabsOptions#newBuilder()} to begin. */ @@ -290,7 +289,7 @@ public Builder withDisabledCustomTabsPackages(List disabledCustomTabsPac * Pass the size in dp; it will be converted to pixels internally. * The minimum height enforced by Chrome is 50% of the screen; values below this are auto-adjusted. * Falls back to full screen on browsers that don't support Partial Custom Tabs (requires Chrome 107+). - * By default, the bottom sheet is resizable by the user. Use {@link #withResizableHeight(boolean)} + * By default, the bottom sheet is resizable by the user. Use {@link #withResizable(boolean)} * to lock the height. * * @param height the initial bottom sheet height in dp. @@ -324,7 +323,10 @@ public Builder withResizable(boolean resizable) { /** * Sets the toolbar's top corner radii in dp. Only takes effect when the Custom Tab is * displayed as a bottom sheet (i.e., when {@link #withInitialHeight(int)} is also set). - * Pass the size in dp. + * Pass the size in dp. The underlying + * {@link CustomTabsIntent.Builder#setToolbarCornerRadiusDp(int)} currently accepts + * values in the range {@code 0}–{@code 16} (inclusive) and will throw an + * {@link IllegalArgumentException} for values outside that range. * * @param cornerRadius the toolbar's top corner radius in dp. * @return this same builder instance. diff --git a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java index ca1f72701..cf1c3c9a2 100644 --- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java @@ -201,7 +201,7 @@ public void shouldSetDisabledCustomTabPackages() { CustomTabsOptions options = CustomTabsOptions.newBuilder() .withBrowserPicker(browserPicker) - .withDisabledCustomTabsPackages(List.of("com.auth0.browser")) + .withDisabledCustomTabsPackages(Collections.singletonList("com.auth0.browser")) .withToolbarColor(android.R.color.black) .build(); assertThat(options, is(notNullValue())); @@ -228,7 +228,7 @@ public void shouldSetDisabledCustomTabPackages() { CustomTabsOptions options2 = CustomTabsOptions.newBuilder() .withBrowserPicker(browserPicker2) - .withDisabledCustomTabsPackages(List.of("com.auth0.browser")) + .withDisabledCustomTabsPackages(Collections.singletonList("com.auth0.browser")) .withToolbarColor(android.R.color.black) .build(); @@ -286,6 +286,20 @@ public void shouldSetInitialActivityHeightWithFixedResize() { assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_FIXED)); } + @Test + public void shouldSetInitialActivityHeightWithAdjustableResize() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(600) + .withResizable(true) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_INITIAL_ACTIVITY_HEIGHT_PX), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_ACTIVITY_HEIGHT_RESIZE_BEHAVIOR, CustomTabsIntent.ACTIVITY_HEIGHT_DEFAULT), is(CustomTabsIntent.ACTIVITY_HEIGHT_ADJUSTABLE)); + } + @Test public void shouldSetToolbarCornerRadius() { CustomTabsOptions options = CustomTabsOptions.newBuilder() @@ -370,6 +384,7 @@ public void shouldSetBackgroundInteractionEnabled() { assertThat(intent, is(notNullValue())); // When background interaction is enabled, EXTRA_DISABLE_BACKGROUND_INTERACTION should be present and false assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + assertThat(intent.getBooleanExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION, true), is(false)); Parcel parcel = Parcel.obtain(); options.writeToParcel(parcel, 0); @@ -377,6 +392,7 @@ public void shouldSetBackgroundInteractionEnabled() { CustomTabsOptions parceledOptions = CustomTabsOptions.CREATOR.createFromParcel(parcel); Intent parceledIntent = parceledOptions.toIntent(context, null); assertThat(parceledIntent.hasExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION), is(true)); + assertThat(parceledIntent.getBooleanExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION, true), is(false)); } @Test @@ -451,7 +467,7 @@ public void shouldNotSetPartialOptionsWhenDisabledBrowser() { CustomTabsOptions options = CustomTabsOptions.newBuilder() .withBrowserPicker(browserPicker) - .withDisabledCustomTabsPackages(List.of("com.auth0.browser")) + .withDisabledCustomTabsPackages(Collections.singletonList("com.auth0.browser")) .withInitialHeight(800) .withToolbarCornerRadius(16) .build(); @@ -462,4 +478,4 @@ public void shouldNotSetPartialOptionsWhenDisabledBrowser() { assertThat(intentNoExtras.getExtras(), is(nullValue())); assertEquals(intentNoExtras.getAction(), "android.intent.action.VIEW"); } -} \ No newline at end of file +} From 448b78d67a591309e45715cae901945e3a702b2b Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 17 Apr 2026 16:46:17 +0530 Subject: [PATCH 4/5] feat: Add documentation for Partial Custom Tabs with bottom sheet and side sheet options --- EXAMPLES.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index da6c10333..1163d863a 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -8,6 +8,7 @@ - [Specify Parameter](#specify-parameter) - [Specify a Custom Authorize URL](#specify-a-custom-authorize-url) - [Customize the Custom Tabs UI](#customize-the-custom-tabs-ui) + - [Partial Custom Tabs (Bottom Sheet and Side Sheet)](#partial-custom-tabs-bottom-sheet-and-side-sheet) - [Changing the Return To URL scheme](#changing-the-return-to-url-scheme) - [Specify a Custom Logout URL](#specify-a-custom-logout-url) - [Trusted Web Activity](#trusted-web-activity) @@ -175,6 +176,75 @@ WebAuthProvider.login(account) ``` +### Partial Custom Tabs (Bottom Sheet and Side Sheet) + +You can present the authentication flow as a **bottom sheet** on compact screens or a **side sheet** on larger screens (e.g., tablets and foldables) instead of a full-screen browser tab. This is configured through `CustomTabsOptions`. + +> **Browser compatibility:** +> - **Bottom sheet** (Partial Custom Tabs) requires **Chrome 107+** (or another Custom Tabs browser that supports the Partial Custom Tabs protocol). +> - **Side sheet** requires **Chrome 120+** (or another browser that supports side-sheet Custom Tabs). +> +> If the user's browser does not meet the minimum version requirement, the authentication flow automatically falls back to a standard full-screen Custom Tab (or a full-screen browser tab if Custom Tabs are unsupported). It is therefore safe to enable these options unconditionally — users on older browsers will simply see the full-screen experience. + +#### Bottom sheet + +```kotlin +val ctOptions = CustomTabsOptions.newBuilder() + .withInitialHeight(700) // initial height in dp + .withResizable(true) // allow the user to drag to resize (default) + .withToolbarCornerRadius(16) // rounded top corners (0–16 dp) + .build() + +WebAuthProvider.login(account) + .withCustomTabsOptions(ctOptions) + .start(this, callback) +``` + +#### Side sheet (with bottom-sheet fallback on narrow screens) + +```kotlin +val ctOptions = CustomTabsOptions.newBuilder() + .withInitialHeight(700) // used when the screen is narrower than the breakpoint + .withInitialWidth(500) // initial side-sheet width in dp + .withSideSheetBreakpoint(840) // screens wider than this render as a side sheet + .build() + +WebAuthProvider.login(account) + .withCustomTabsOptions(ctOptions) + .start(this, callback) +``` + +If `withSideSheetBreakpoint` is not set, the browser's default breakpoint (typically 840 dp in Chrome) applies, so devices narrower than that will continue to render as a bottom sheet or full screen. + +#### Allow interaction with the app behind the partial tab + +By default, the app behind a Partial Custom Tab is non-interactive. Enable pass-through interaction with: + +```kotlin +val ctOptions = CustomTabsOptions.newBuilder() + .withInitialHeight(700) + .withBackgroundInteractionEnabled(true) + .build() +``` + +

+ Using Java + +```java +CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(700) + .withInitialWidth(500) + .withSideSheetBreakpoint(840) + .withToolbarCornerRadius(16) + .withBackgroundInteractionEnabled(true) + .build(); + +WebAuthProvider.login(account) + .withCustomTabsOptions(options) + .start(MainActivity.this, callback); +``` +
+ ## Changing the Return To URL scheme This configuration will probably match what you've done for the [authentication setup](#a-note-about-app-deep-linking). From a84a32b2727397af0e5c8ddc16927c522caceab9 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Fri, 17 Apr 2026 16:49:23 +0530 Subject: [PATCH 5/5] feat: Clamp toolbar corner radius in CustomTabsOptions and add tests for boundary conditions --- .../android/provider/CustomTabsOptions.java | 15 ++++++++---- .../provider/CustomTabsOptionsTest.java | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java index 83a818a94..f487c7f5b 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -324,15 +324,22 @@ public Builder withResizable(boolean resizable) { * Sets the toolbar's top corner radii in dp. Only takes effect when the Custom Tab is * displayed as a bottom sheet (i.e., when {@link #withInitialHeight(int)} is also set). * Pass the size in dp. The underlying - * {@link CustomTabsIntent.Builder#setToolbarCornerRadiusDp(int)} currently accepts - * values in the range {@code 0}–{@code 16} (inclusive) and will throw an - * {@link IllegalArgumentException} for values outside that range. + * {@link CustomTabsIntent.Builder#setToolbarCornerRadiusDp(int)} only accepts values in + * the range {@code 0}–{@code 16} (inclusive); to avoid a runtime crash, values + * outside that range are clamped: negative values are treated as {@code 0} and values + * greater than {@code 16} are capped to {@code 16}. * - * @param cornerRadius the toolbar's top corner radius in dp. + * @param cornerRadius the toolbar's top corner radius in dp. Clamped to {@code [0, 16]}. * @return this same builder instance. */ @NonNull public Builder withToolbarCornerRadius(@Dimension(unit = Dimension.DP) int cornerRadius) { + if (cornerRadius < 0) { + cornerRadius = 0; + } + if (cornerRadius > 16) { + cornerRadius = 16; + } this.toolbarCornerRadius = cornerRadius; return this; } diff --git a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java index cf1c3c9a2..a3cd10c02 100644 --- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java @@ -322,6 +322,30 @@ public void shouldSetToolbarCornerRadius() { assertThat(parceledIntent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); } + @Test + public void shouldClampToolbarCornerRadiusAboveMax() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .withToolbarCornerRadius(50) + .build(); + + Intent intent = options.toIntent(context, null); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(true)); + assertThat(intent.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP, 0), is(16)); + } + + @Test + public void shouldClampNegativeToolbarCornerRadiusToZero() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withInitialHeight(800) + .withToolbarCornerRadius(-10) + .build(); + + Intent intent = options.toIntent(context, null); + // Clamped to 0; since the toIntent guard is `if (toolbarCornerRadius > 0)`, no extra is set. + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_TOOLBAR_CORNER_RADIUS_DP), is(false)); + } + // --- Partial Custom Tabs: Side Sheet --- // Note: withInitialWidth accepts dp; converted to px internally (1:1 in Robolectric mdpi).