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). 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..f487c7f5b 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -7,8 +7,10 @@ import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; +import android.util.DisplayMetrics; import androidx.annotation.ColorRes; +import androidx.annotation.Dimension; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.browser.customtabs.CustomTabColorSchemeParams; @@ -34,11 +36,33 @@ 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; } @Nullable @@ -88,6 +112,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 +154,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 +168,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 +205,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 +283,118 @@ 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 #withResizable(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. The underlying + * {@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. 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; + } + + /** + * 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 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. + * + * @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. + *

+ * 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. + */ + @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,8 +402,14 @@ 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); } } + private int dpToPx(@NonNull Context context, int dp) { + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + return Math.round(dp * metrics.density); + } } 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..a3cd10c02 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 @@ -187,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())); @@ -214,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(); @@ -224,4 +238,268 @@ public void shouldSetDisabledCustomTabPackages() { int resolvedColor = ContextCompat.getColor(activity, android.R.color.black); assertThat(intentWithToolbarExtra.getIntExtra(CustomTabsIntent.EXTRA_TOOLBAR_COLOR, 0), is(resolvedColor)); } -} \ No newline at end of file + + // --- 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 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() + .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)); + } + + @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). + + @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)); + assertThat(intent.getBooleanExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION, true), is(false)); + + 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)); + assertThat(parceledIntent.getBooleanExtra(CustomTabsIntent.EXTRA_DISABLE_BACKGROUND_INTERACTION, true), is(false)); + } + + @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(Collections.singletonList("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"); + } +}