From 8057d6dc4c280f6c6be1510a8d9f115cf25e6e7f Mon Sep 17 00:00:00 2001 From: junki ishida <7507269+jishidaaaaa@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:35:34 +0900 Subject: [PATCH] feat: allow enabling EXTRA_SEND_TO_EXTERNAL_DEFAULT_HANDLER on the Custom Tab intent Adds CustomTabsOptions.Builder#withSendToExternalDefaultHandlerEnabled(boolean). When enabled, toIntent() sets CustomTabsIntent.EXTRA_SEND_TO_EXTERNAL_DEFAULT_HANDLER so that the initial navigation chain of the Custom Tab may leave the browser and launch the app. This lets https (App Links) callbacks reach the app on Chrome versions prior to 120 when /authorize redirects straight to the callback URL without user interaction (existing session, prompt=none). Disabled by default. --- .../android/provider/CustomTabsOptions.java | 47 ++++++++++++++++++- .../provider/CustomTabsOptionsTest.java | 45 ++++++++++++++++++ 2 files changed, 90 insertions(+), 2 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 a9c4a8da..dc7753a1 100644 --- a/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java +++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsOptions.java @@ -56,6 +56,8 @@ public class CustomTabsOptions implements Parcelable { // Partial Custom Tabs - Background Interaction private final boolean backgroundInteractionEnabled; + private final boolean sendToExternalDefaultHandlerEnabled; + private final boolean authTab; private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNull BrowserPicker browserPicker, @@ -63,7 +65,7 @@ private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNul int initialHeight, int activityHeightResizeBehavior, int toolbarCornerRadius, int initialWidth, int sideSheetBreakpoint, boolean backgroundInteractionEnabled, boolean ephemeralBrowsing, - boolean authTab) { + boolean sendToExternalDefaultHandlerEnabled, boolean authTab) { this.showTitle = showTitle; this.toolbarColor = toolbarColor; this.browserPicker = browserPicker; @@ -75,6 +77,7 @@ private CustomTabsOptions(boolean showTitle, @ColorRes int toolbarColor, @NonNul this.initialWidth = initialWidth; this.sideSheetBreakpoint = sideSheetBreakpoint; this.backgroundInteractionEnabled = backgroundInteractionEnabled; + this.sendToExternalDefaultHandlerEnabled = sendToExternalDefaultHandlerEnabled; this.authTab = authTab; } @@ -114,6 +117,7 @@ Builder toBuilder() { builder.initialWidth = this.initialWidth; builder.sideSheetBreakpoint = this.sideSheetBreakpoint; builder.backgroundInteractionEnabled = this.backgroundInteractionEnabled; + builder.sendToExternalDefaultHandlerEnabled = this.sendToExternalDefaultHandlerEnabled; builder.ephemeralBrowsing = this.ephemeralBrowsing; builder.authTab = this.authTab; return builder; @@ -191,6 +195,14 @@ Intent toIntent(@NonNull Context context, @Nullable CustomTabsSession session) { builder.setBackgroundInteractionEnabled(true); } + // Allow the initial navigation chain (e.g. /authorize redirecting straight to an https + // callback URL) to leave the browser and launch this app. Without this, Chrome versions + // prior to 120 keep such redirects inside the Custom Tab because the launching intent + // explicitly targets the browser package. + if (sendToExternalDefaultHandlerEnabled) { + builder.setSendToExternalDefaultHandlerEnabled(true); + } + return builder.build().intent; } @@ -241,6 +253,7 @@ protected CustomTabsOptions(@NonNull Parcel in) { initialWidth = in.readInt(); sideSheetBreakpoint = in.readInt(); backgroundInteractionEnabled = in.readByte() != 0; + sendToExternalDefaultHandlerEnabled = in.readByte() != 0; authTab = in.readByte() != 0; } @@ -257,6 +270,7 @@ public void writeToParcel(@NonNull Parcel dest, int flags) { dest.writeInt(initialWidth); dest.writeInt(sideSheetBreakpoint); dest.writeByte((byte) (backgroundInteractionEnabled ? 1 : 0)); + dest.writeByte((byte) (sendToExternalDefaultHandlerEnabled ? 1 : 0)); dest.writeByte((byte) (authTab ? 1 : 0)); } @@ -298,6 +312,7 @@ public static class Builder { private int initialWidth; private int sideSheetBreakpoint; private boolean backgroundInteractionEnabled; + private boolean sendToExternalDefaultHandlerEnabled; Builder() { this.showTitle = false; @@ -312,6 +327,7 @@ public static class Builder { this.initialWidth = 0; this.sideSheetBreakpoint = 0; this.backgroundInteractionEnabled = false; + this.sendToExternalDefaultHandlerEnabled = false; } /** @@ -494,6 +510,33 @@ public Builder withBackgroundInteractionEnabled(boolean enabled) { return this; } + /** + * Allows the initial navigation chain of the Custom Tab to leave the browser and launch + * an external app, by setting {@link CustomTabsIntent#EXTRA_SEND_TO_EXTERNAL_DEFAULT_HANDLER} + * on the Custom Tab intent. + *

+ * This is relevant when the redirect scheme is {@code https} (Android App Links). A Custom + * Tab intent created from a Custom Tabs session always targets the browser package + * explicitly, and Chrome versions prior to 120 interpret that as a request to keep the + * initial navigation chain inside the browser. When the {@code /authorize} request redirects + * straight to the callback URL without any user interaction, for example because the user + * already has a session or {@code prompt=none} is used, those Chrome versions load the + * callback URL in the Custom Tab instead of launching the app, and the user sees a + * "Not found." page. Enabling this option lets the redirect launch the app instead. + *

+ * It has no effect on flows that already require user interaction, on Chrome 120 and + * later for redirects back to the calling app, or on the Auth Tab flow. + * By default, this option is disabled. + * + * @param enabled whether to allow the initial navigation chain to launch an external app. + * @return this same builder instance. + */ + @NonNull + public Builder withSendToExternalDefaultHandlerEnabled(boolean enabled) { + this.sendToExternalDefaultHandlerEnabled = enabled; + return this; + } + /** * Create a new CustomTabsOptions instance with the customization settings. * @@ -504,7 +547,7 @@ public CustomTabsOptions build() { return new CustomTabsOptions(showTitle, toolbarColor, browserPicker, disabledCustomTabsPackages, initialHeight, activityHeightResizeBehavior, toolbarCornerRadius, initialWidth, sideSheetBreakpoint, backgroundInteractionEnabled, ephemeralBrowsing, - authTab); + sendToExternalDefaultHandlerEnabled, authTab); } } 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 d77fba74..ee905599 100644 --- a/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsOptionsTest.java @@ -745,6 +745,51 @@ public void shouldNotSetPartialOptionsWhenDisabledBrowser() { assertEquals(intentNoExtras.getAction(), "android.intent.action.VIEW"); } + // --- Send To External Default Handler --- + + @Test + public void shouldSetSendToExternalDefaultHandlerEnabled() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withSendToExternalDefaultHandlerEnabled(true) + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_SEND_TO_EXTERNAL_DEFAULT_HANDLER), is(true)); + assertThat(CustomTabsIntent.isSendToExternalDefaultHandlerEnabled(intent), 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(CustomTabsIntent.isSendToExternalDefaultHandlerEnabled(parceledIntent), is(true)); + } + + @Test + public void shouldNotSetSendToExternalDefaultHandlerByDefault() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .build(); + assertThat(options, is(notNullValue())); + + Intent intent = options.toIntent(context, null); + assertThat(intent, is(notNullValue())); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_SEND_TO_EXTERNAL_DEFAULT_HANDLER), is(false)); + assertThat(CustomTabsIntent.isSendToExternalDefaultHandlerEnabled(intent), is(false)); + } + + @Test + public void shouldKeepSendToExternalDefaultHandlerWhenCopied() { + CustomTabsOptions options = CustomTabsOptions.newBuilder() + .withSendToExternalDefaultHandlerEnabled(true) + .build(); + + CustomTabsOptions copy = options.copyWithEphemeralBrowsing(); + Intent intent = copy.toIntent(context, null); + assertThat(CustomTabsIntent.isSendToExternalDefaultHandlerEnabled(intent), is(true)); + } + // --- Auth Tab --- @Test