From 721e1c56483ca50037b2de7b4b3124be571f4ce6 Mon Sep 17 00:00:00 2001 From: Erick Date: Wed, 26 Nov 2025 14:19:17 -0800 Subject: [PATCH 01/11] Adds fallback to launch authentication in external browser --- .../authentication/AuthenticationActivity.kt | 49 ++++++++++++++----- .../toolkit/authentication/Extensions.kt | 45 +++++++++++++++++ 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt index 077a46482..459e7ebc6 100644 --- a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt +++ b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt @@ -123,14 +123,11 @@ private const val VALUE_INTENT_EXTRA_PROMPT_TYPE_SIGN_OUT = "SIGN_OUT" * @since 200.8.0 */ public class AuthenticationActivity internal constructor() : ComponentActivity() { + private val redirectUri = intent.getStringExtra("REDIRECT_URI") ?: "" public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val url = intent.getStringExtra(KEY_INTENT_EXTRA_URL) - url?.let { - val preferPrivateWebBrowserSession = intent.getBooleanExtra(KEY_INTENT_EXTRA_PRIVATE_BROWSING, false) - launchCustomTabs(it, preferPrivateWebBrowserSession) - } + initiateAuthenticationFlow() } // This override gets called first when the CustomTabs close button or the back button is pressed. @@ -138,9 +135,8 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() super.onWindowFocusChanged(hasFocus) // We only want to respond to focus changed events when this activity is in "resumed" state. - // On some devices (Oreo) we get unexpected focus changed events with hasFocus true which cause this Activity + // On some devices we get unexpected focus changed events with hasFocus true which cause this Activity // to be finished (destroyed) prematurely, for example: - // - On Oreo log in to portal with OAuth // - When the browser window is launched this triggers a focus changed event with hasFocus true but at this point // we do not want to finish this activity -> at this point the activity is in paused state (isResumed == false) so // we can use this to ignore this "rogue" focus changed event. @@ -175,11 +171,10 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() * @since 200.8.0 */ private fun handleRedirectIntent(intent: Intent?) { - val uri = intent?.data - if (uri != null) { - val uriString = uri.toString() + val uri = intent?.data?.toString() + if (uri != null && isValidRedirectUri(uri)) { val newIntent = Intent().apply { - putExtra(KEY_INTENT_EXTRA_RESPONSE_URI, uriString) + putExtra(KEY_INTENT_EXTRA_RESPONSE_URI, uri) } setResult(RESULT_CODE_SUCCESS, newIntent) } else { @@ -188,6 +183,32 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() finish() } + /** + * Handles the authentication challenge by launching either an external browser or a Custom Tab + * based on the intent extras and provided URL. + * If no URL is provided, it attempts to handle the redirect intent. + * @since 300.0.0 + */ + private fun initiateAuthenticationFlow() = with(intent) { + val shouldLaunchInExternalBrowser = getBooleanExtra(KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER, false) + val url = getStringExtra(KEY_INTENT_EXTRA_URL) ?: return handleRedirectIntent(this) + if (shouldLaunchInExternalBrowser) { + launchInExternalBrowser(url) + } else { + launchCustomTabs(url, getBooleanExtra(KEY_INTENT_EXTRA_PRIVATE_BROWSING, false)) + } + } + + /** + * Validates if the provided URI starts with the expected redirect URI. + * @param uri the URI to validate. + * @return true if the URI is valid, false otherwise. + * @since 300.0.0 + */ + private fun isValidRedirectUri(uri: String): Boolean { + return uri.startsWith(redirectUri) + } + /** * An ActivityResultContract that takes a [OAuthUserSignIn] as input and returns a nullable * string as output. The output string represents a redirect URI as the result of an OAuth user @@ -202,7 +223,13 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() override fun createIntent(context: Context, input: OAuthUserSignIn): Intent = Intent(context, AuthenticationActivity::class.java).apply { putExtra(KEY_INTENT_EXTRA_URL, input.authorizeUrl) + putExtra("REDIRECT_URI", input.oAuthUserConfiguration.redirectUrl) putExtra(KEY_INTENT_EXTRA_PRIVATE_BROWSING, input.oAuthUserConfiguration.preferPrivateWebBrowserSession) + if (context.isCustomTabsSupported()) { + putExtra(KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER, false) + } else { + putExtra(KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER, true) + } } override fun parseResult(resultCode: Int, intent: Intent?): String? { diff --git a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt index 90ee02cab..726fab731 100644 --- a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt +++ b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt @@ -19,7 +19,11 @@ package com.arcgismaps.toolkit.authentication import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri import androidx.browser.customtabs.CustomTabsIntent +import androidx.browser.customtabs.CustomTabsService import com.arcgismaps.httpcore.authentication.AuthenticationManager import com.arcgismaps.httpcore.authentication.OAuthUserConfiguration import com.arcgismaps.httpcore.authentication.OAuthUserCredential @@ -85,3 +89,44 @@ internal fun Activity.launchCustomTabs(authorizeUrl: String, preferPrivateWebBro } }.build().launchUrl(this, authorizeUrl.toUri()) } + +/** + * Launches an external browser with the provided authorize URL. + * + * @param authorizeUrl the authorize URL used by the external browser to prompt for OAuth + * user credentials. + * + * @since 300.0.0 + */ +internal fun Activity.launchInExternalBrowser(authorizeUrl: String) { + val intent = Intent(Intent.ACTION_VIEW, authorizeUrl.toUri()).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + } + startActivity(intent) +} + +//TODO Figure out a way to get the package name to launch Custom Tabs with +internal fun Context.getPackageNameToLaunchUrl() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("http://www.example.com")) + val defaultActivity = packageManager.resolveActivity(intent, 0)?.activityInfo?.packageName +} + +/** + * Returns true if there is at least one browser on the device that supports Custom Tabs. + * + * @since 300.0.0 + */ +internal fun Context.isCustomTabsSupported(): Boolean { + val pm = packageManager + // Generic http VIEW intent used to discover browser activities. + val activityIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://www.example.com")) + val resolvedActivityList = pm.queryIntentActivities(activityIntent, 0) + val serviceIntent = Intent(CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION) + for (info in resolvedActivityList) { + serviceIntent.`package` = info.activityInfo.packageName + if (pm.resolveService(serviceIntent, 0) != null) { + return true + } + } + return false +} From b322d0238dd4f72fc8c108df1964617f7253e0c8 Mon Sep 17 00:00:00 2001 From: Erick Date: Wed, 26 Nov 2025 14:19:47 -0800 Subject: [PATCH 02/11] adds additional flag --- .../arcgismaps/toolkit/authentication/AuthenticationActivity.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt index 459e7ebc6..5414b86f3 100644 --- a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt +++ b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt @@ -29,6 +29,7 @@ private const val KEY_INTENT_EXTRA_RESPONSE_URI = "KEY_INTENT_EXTRA_RESPONSE_URI private const val KEY_INTENT_EXTRA_PROMPT_TYPE = "KEY_INTENT_EXTRA_PROMPT_TYPE" private const val KEY_INTENT_EXTRA_PRIVATE_BROWSING = "KEY_INTENT_EXTRA_PRIVATE_BROWSING" private const val KEY_INTENT_EXTRA_IAP_SIGN_OUT_RESPONSE = "KEY_INTENT_EXTRA_IAP_SIGN_OUT_RESPONSE" +private const val KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER = "KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER" private const val RESULT_CODE_SUCCESS = 1 private const val RESULT_CODE_CANCELED = 2 From 2cc7ac8b1102b1fdd1a1d590c7093b9c74cfbc67 Mon Sep 17 00:00:00 2001 From: Erick Date: Wed, 26 Nov 2025 16:26:39 -0800 Subject: [PATCH 03/11] adds espresso test --- gradle/libs.versions.toml | 1 + toolkit/authentication/build.gradle.kts | 1 + ...thenticationActivityExternalBrowserTest.kt | 84 +++++++++++++++++++ .../authentication/AuthenticationActivity.kt | 2 +- 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ccbf30865..b8724c5bb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,7 @@ androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "medi androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "media3Exoplayer" } androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxTestExt" } androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" } +androidx-test-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "androidxEspresso" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidXTestRunner" } androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidXTestRules" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } diff --git a/toolkit/authentication/build.gradle.kts b/toolkit/authentication/build.gradle.kts index 80bb6fa4f..fdb663bd4 100644 --- a/toolkit/authentication/build.gradle.kts +++ b/toolkit/authentication/build.gradle.kts @@ -116,4 +116,5 @@ dependencies { // uiautomator androidTestImplementation(libs.androidx.uiautomator) androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.androidx.test.espresso.intents) } diff --git a/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt b/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt new file mode 100644 index 000000000..b1e18c13c --- /dev/null +++ b/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2025 Esri + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.arcgismaps.toolkit.authentication + +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.espresso.intent.matcher.IntentMatchers.hasData +import androidx.test.espresso.intent.matcher.IntentMatchers.hasCategories +import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtraWithKey +//import androidx.test.espresso.intent.matcher.IntentMatchers.toPackage +//import androidx.test.espresso.intent.matcher.UriMatchers.withScheme +//import androidx.test.espresso.intent.matcher.UriMatchers.withHost +//import androidx.test.espresso.intent.matcher.UriMatchers.withPath +import androidx.test.core.app.ActivityScenario +import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.CoreMatchers.not +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumentation test verifying that when Custom Tabs are not supported we fall back to launching + * an external browser (plain ACTION_VIEW intent with CATEGORY_BROWSABLE) instead of a Custom Tab. + * We force this path by explicitly setting the private extra KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER + * to true (using the literal string name as the constant is private to [AuthenticationActivity]). + */ +@RunWith(AndroidJUnit4::class) +class AuthenticationActivityExternalBrowserTest { + + @Before + fun setUp() { + Intents.init() + } + + @After + fun tearDown() { + Intents.release() + } + + @Test + fun launchesExternalBrowserWhenCustomTabsNotSupported() { + // Arrange + val authorizeUrl = "https://example.com/auth" + val intent = Intent(ApplicationProvider.getApplicationContext(), AuthenticationActivity::class.java).apply { + // Force external browser path; KEY_INTENT_EXTRA_URL and KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER are private constants. + putExtra("KEY_INTENT_EXTRA_URL", authorizeUrl) + putExtra("KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER", true) + } + + // Act: launch the AuthenticationActivity which should immediately start the external browser intent. + ActivityScenario.launch(intent).use { + // Assert: An ACTION_VIEW intent for the authorize URL was fired with CATEGORY_BROWSABLE + // and WITHOUT the Custom Tabs session extra key (which would be present for a CustomTabsIntent). + intended(allOf( + hasAction(Intent.ACTION_VIEW), + hasData(authorizeUrl), + hasCategories(setOf(Intent.CATEGORY_BROWSABLE)), + // Custom Tabs adds android.support.customtabs.extra.SESSION; ensure absent. + not(hasExtraWithKey("android.support.customtabs.extra.SESSION")) + )) + } + } +} + diff --git a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt index 5414b86f3..18b5fefe1 100644 --- a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt +++ b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt @@ -124,7 +124,7 @@ private const val VALUE_INTENT_EXTRA_PROMPT_TYPE_SIGN_OUT = "SIGN_OUT" * @since 200.8.0 */ public class AuthenticationActivity internal constructor() : ComponentActivity() { - private val redirectUri = intent.getStringExtra("REDIRECT_URI") ?: "" + private val redirectUri = intent?.getStringExtra("REDIRECT_URI") ?: "" public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) From e8e49ac8d8df9b8056bed0b57001e5247b92d8f5 Mon Sep 17 00:00:00 2001 From: Erick Date: Mon, 1 Dec 2025 12:13:22 -0800 Subject: [PATCH 04/11] adds test to verify AuthenticationActivity's behavior when external browser is launched --- ...thenticationActivityExternalBrowserTest.kt | 100 ++++++++++++++---- .../src/main/AndroidManifest.xml | 7 ++ .../authentication/AuthenticationActivity.kt | 10 +- .../toolkit/authentication/Extensions.kt | 41 +++---- 4 files changed, 111 insertions(+), 47 deletions(-) diff --git a/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt b/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt index b1e18c13c..65dc7e19e 100644 --- a/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt +++ b/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt @@ -17,20 +17,17 @@ package com.arcgismaps.toolkit.authentication import android.content.Intent +import android.net.Uri import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.espresso.intent.Intents -import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.intent.Intents.intended import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.matcher.IntentMatchers.hasData import androidx.test.espresso.intent.matcher.IntentMatchers.hasCategories import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtraWithKey -//import androidx.test.espresso.intent.matcher.IntentMatchers.toPackage -//import androidx.test.espresso.intent.matcher.UriMatchers.withScheme -//import androidx.test.espresso.intent.matcher.UriMatchers.withHost -//import androidx.test.espresso.intent.matcher.UriMatchers.withPath import androidx.test.core.app.ActivityScenario +import com.google.common.truth.Truth.assertThat import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.not import org.junit.After @@ -39,10 +36,9 @@ import org.junit.Test import org.junit.runner.RunWith /** - * Instrumentation test verifying that when Custom Tabs are not supported we fall back to launching - * an external browser (plain ACTION_VIEW intent with CATEGORY_BROWSABLE) instead of a Custom Tab. - * We force this path by explicitly setting the private extra KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER - * to true (using the literal string name as the constant is private to [AuthenticationActivity]). + * Instrumentation test to verify [AuthenticationActivity]'s behavior. + * + * @since 300.0.0 */ @RunWith(AndroidJUnit4::class) class AuthenticationActivityExternalBrowserTest { @@ -57,28 +53,86 @@ class AuthenticationActivityExternalBrowserTest { Intents.release() } + /** + * Given [AuthenticationActivity] is launched with an intent containing the flag KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER + * set to false + * When [AuthenticationActivity] starts + * Then an intent should be fired with ACTION_VIEW, simulating a Custom Tabs launch + * And the intent contains the Custom Tabs session extra key + * @since 300.0.0 + */ + @Test + fun launchesCustomTabsWhenExternalBrowserFlagIsFalse() { + val authorizeUrl = "https://example.com/auth" + val intent = Intent(ApplicationProvider.getApplicationContext(), AuthenticationActivity::class.java).apply { + putExtra(KEY_INTENT_EXTRA_URL, authorizeUrl) + putExtra(KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER, false) + } + + ActivityScenario.launch(intent).use { + intended( + allOf( + hasAction(Intent.ACTION_VIEW), + hasData(authorizeUrl), + // Custom Tabs adds android.support.customtabs.extra.SESSION + hasExtraWithKey("android.support.customtabs.extra.SESSION") + + ) + ) + } + } + + /** + * Given [AuthenticationActivity] is launched with an intent containing the flag KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER + * set to true + * When [AuthenticationActivity] starts + * Then an intent should be fired with ACTION_VIEW, simulating an external browser launch + * And the intent does not contain the Custom Tabs session extra key + * @since 300.0.0 + */ @Test fun launchesExternalBrowserWhenCustomTabsNotSupported() { - // Arrange val authorizeUrl = "https://example.com/auth" val intent = Intent(ApplicationProvider.getApplicationContext(), AuthenticationActivity::class.java).apply { - // Force external browser path; KEY_INTENT_EXTRA_URL and KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER are private constants. - putExtra("KEY_INTENT_EXTRA_URL", authorizeUrl) - putExtra("KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER", true) + putExtra(KEY_INTENT_EXTRA_URL, authorizeUrl) + putExtra(KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER, true) // Force external browser launch } - // Act: launch the AuthenticationActivity which should immediately start the external browser intent. + // AuthenticationActivity will start the external browser intent ActivityScenario.launch(intent).use { - // Assert: An ACTION_VIEW intent for the authorize URL was fired with CATEGORY_BROWSABLE - // and WITHOUT the Custom Tabs session extra key (which would be present for a CustomTabsIntent). - intended(allOf( - hasAction(Intent.ACTION_VIEW), - hasData(authorizeUrl), - hasCategories(setOf(Intent.CATEGORY_BROWSABLE)), - // Custom Tabs adds android.support.customtabs.extra.SESSION; ensure absent. - not(hasExtraWithKey("android.support.customtabs.extra.SESSION")) - )) + intended( + allOf( + hasAction(Intent.ACTION_VIEW), + hasData(authorizeUrl), + hasCategories(setOf(Intent.CATEGORY_BROWSABLE)), + // Custom Tabs adds android.support.customtabs.extra.SESSION extra + not(hasExtraWithKey("android.support.customtabs.extra.SESSION")) + ) + ) } } + + /** + * Given [AuthenticationActivity] is launched with an intent containing a valid redirect URI + * When the activity starts + * Then the activity finishes with RESULT_CODE_SUCCESS and includes the the redirect URI in the result data + * @since 300.0.0 + */ + @Test + fun returnsSuccessWhenValidRedirectUriProvided() { + val redirectUri = "kotlin-iap-test-1://auth/callback?code=123" + // simulates the redirect from the external browser + val intent = Intent( + Intent.ACTION_VIEW, + Uri.parse(redirectUri) + ) + + // invoke the activity with the redirect URI + val scenario = ActivityScenario.launchActivityForResult(intent) + scenario.close() + val result = scenario.result + assertThat(result.resultCode).isEqualTo(1) + assertThat(result.resultData?.getStringExtra("KEY_INTENT_EXTRA_RESPONSE_URI")).isEqualTo(redirectUri) + } } diff --git a/toolkit/authentication/src/main/AndroidManifest.xml b/toolkit/authentication/src/main/AndroidManifest.xml index 9b94d167c..6f69f082a 100644 --- a/toolkit/authentication/src/main/AndroidManifest.xml +++ b/toolkit/authentication/src/main/AndroidManifest.xml @@ -18,5 +18,12 @@ --> + + + + + + diff --git a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt index 18b5fefe1..618203cc2 100644 --- a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt +++ b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt @@ -24,12 +24,12 @@ import androidx.activity.result.contract.ActivityResultContract import androidx.lifecycle.Lifecycle import com.arcgismaps.httpcore.authentication.OAuthUserSignIn -private const val KEY_INTENT_EXTRA_URL = "KEY_INTENT_EXTRA_URL" +internal const val KEY_INTENT_EXTRA_URL = "KEY_INTENT_EXTRA_URL" private const val KEY_INTENT_EXTRA_RESPONSE_URI = "KEY_INTENT_EXTRA_RESPONSE_URI" private const val KEY_INTENT_EXTRA_PROMPT_TYPE = "KEY_INTENT_EXTRA_PROMPT_TYPE" private const val KEY_INTENT_EXTRA_PRIVATE_BROWSING = "KEY_INTENT_EXTRA_PRIVATE_BROWSING" private const val KEY_INTENT_EXTRA_IAP_SIGN_OUT_RESPONSE = "KEY_INTENT_EXTRA_IAP_SIGN_OUT_RESPONSE" -private const val KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER = "KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER" +internal const val KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER = "KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER" private const val RESULT_CODE_SUCCESS = 1 private const val RESULT_CODE_CANCELED = 2 @@ -151,7 +151,7 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() setResult(RESULT_CODE_SUCCESS, newIntent) } else { // if we got here the user must have pressed the back button or the x button while the - // custom tab was visible + // browser was visible setResult(RESULT_CODE_CANCELED, Intent()) } finish() @@ -192,6 +192,7 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() */ private fun initiateAuthenticationFlow() = with(intent) { val shouldLaunchInExternalBrowser = getBooleanExtra(KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER, false) + // get the URL to launch or we assume we are redirecting back to the app val url = getStringExtra(KEY_INTENT_EXTRA_URL) ?: return handleRedirectIntent(this) if (shouldLaunchInExternalBrowser) { launchInExternalBrowser(url) @@ -226,9 +227,10 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() putExtra(KEY_INTENT_EXTRA_URL, input.authorizeUrl) putExtra("REDIRECT_URI", input.oAuthUserConfiguration.redirectUrl) putExtra(KEY_INTENT_EXTRA_PRIVATE_BROWSING, input.oAuthUserConfiguration.preferPrivateWebBrowserSession) - if (context.isCustomTabsSupported()) { + if (context.isCustomTabsSupportedByDefaultBrowser()) { putExtra(KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER, false) } else { + // TODO: Pass the package name to launch Custom Tabs with putExtra(KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER, true) } } diff --git a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt index 726fab731..6995d3a0f 100644 --- a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt +++ b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt @@ -21,13 +21,13 @@ package com.arcgismaps.toolkit.authentication import android.app.Activity import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri +import android.util.Log +import androidx.browser.customtabs.CustomTabsClient import androidx.browser.customtabs.CustomTabsIntent -import androidx.browser.customtabs.CustomTabsService import com.arcgismaps.httpcore.authentication.AuthenticationManager -import com.arcgismaps.httpcore.authentication.OAuthUserConfiguration import com.arcgismaps.httpcore.authentication.OAuthUserCredential -import com.arcgismaps.httpcore.authentication.OAuthUserSignIn import androidx.core.net.toUri /** @@ -105,28 +105,29 @@ internal fun Activity.launchInExternalBrowser(authorizeUrl: String) { startActivity(intent) } -//TODO Figure out a way to get the package name to launch Custom Tabs with -internal fun Context.getPackageNameToLaunchUrl() { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("http://www.example.com")) - val defaultActivity = packageManager.resolveActivity(intent, 0)?.activityInfo?.packageName +/** + * Returns true if there is at least one browser on the device that supports Custom Tabs. + * @since 300.0.0 + */ +internal fun Context.isCustomTabsSupportedByDefaultBrowser(): Boolean { + // first check if the default browser supports Custom Tabs + val packageName = CustomTabsClient.getPackageName(this, emptyList()) + return packageName != null } /** - * Returns true if there is at least one browser on the device that supports Custom Tabs. - * + * Returns the package name of a browser that supports Custom Tabs, or null if none is found. * @since 300.0.0 */ -internal fun Context.isCustomTabsSupported(): Boolean { +internal fun Context.getPackageThatSupportsCustomTabs(): String? { val pm = packageManager - // Generic http VIEW intent used to discover browser activities. - val activityIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://www.example.com")) - val resolvedActivityList = pm.queryIntentActivities(activityIntent, 0) - val serviceIntent = Intent(CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION) - for (info in resolvedActivityList) { - serviceIntent.`package` = info.activityInfo.packageName - if (pm.resolveService(serviceIntent, 0) != null) { - return true - } + val activityIntent = Intent(Intent.ACTION_VIEW, "http://www.example.com".toUri()) + val resolvedActivityList = pm.queryIntentActivities(activityIntent, PackageManager.MATCH_ALL) + + val packageNames = resolvedActivityList.map { + it.activityInfo.packageName } - return false + Log.d("ArcGIS-Main", ".getPackageThatSupportsCustomTabs: $packageNames") + + return CustomTabsClient.getPackageName(this, packageNames, true) ?: null } From 6eb01a9461722d2a37628d396ef201e3dd686753 Mon Sep 17 00:00:00 2001 From: Erick Date: Mon, 1 Dec 2025 13:43:54 -0800 Subject: [PATCH 05/11] refactor AuthenticationActivity to improve redirect URI handling --- .../authentication/AuthenticationActivity.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt index 618203cc2..fe29bd2da 100644 --- a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt +++ b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt @@ -18,6 +18,7 @@ package com.arcgismaps.toolkit.authentication import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContract @@ -25,6 +26,7 @@ import androidx.lifecycle.Lifecycle import com.arcgismaps.httpcore.authentication.OAuthUserSignIn internal const val KEY_INTENT_EXTRA_URL = "KEY_INTENT_EXTRA_URL" +private const val KEY_INTENT_EXTRA_REDIRECT_URL = "KEY_INTENT_EXTRA_REDIRECT_URL" private const val KEY_INTENT_EXTRA_RESPONSE_URI = "KEY_INTENT_EXTRA_RESPONSE_URI" private const val KEY_INTENT_EXTRA_PROMPT_TYPE = "KEY_INTENT_EXTRA_PROMPT_TYPE" private const val KEY_INTENT_EXTRA_PRIVATE_BROWSING = "KEY_INTENT_EXTRA_PRIVATE_BROWSING" @@ -124,7 +126,6 @@ private const val VALUE_INTENT_EXTRA_PROMPT_TYPE_SIGN_OUT = "SIGN_OUT" * @since 200.8.0 */ public class AuthenticationActivity internal constructor() : ComponentActivity() { - private val redirectUri = intent?.getStringExtra("REDIRECT_URI") ?: "" public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -172,10 +173,10 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() * @since 200.8.0 */ private fun handleRedirectIntent(intent: Intent?) { - val uri = intent?.data?.toString() - if (uri != null && isValidRedirectUri(uri)) { + val uri = intent?.data + if (isValidRedirectUri(uri)) { val newIntent = Intent().apply { - putExtra(KEY_INTENT_EXTRA_RESPONSE_URI, uri) + putExtra(KEY_INTENT_EXTRA_RESPONSE_URI, uri.toString()) } setResult(RESULT_CODE_SUCCESS, newIntent) } else { @@ -207,8 +208,11 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() * @return true if the URI is valid, false otherwise. * @since 300.0.0 */ - private fun isValidRedirectUri(uri: String): Boolean { - return uri.startsWith(redirectUri) + private fun isValidRedirectUri(uri: Uri?): Boolean { + if (uri == null) return false + val expectedRedirectUri = intent.getStringExtra(KEY_INTENT_EXTRA_REDIRECT_URL) ?: "" + val incomingRedirectUri = "${uri.scheme}://${uri.host}" + return incomingRedirectUri == expectedRedirectUri } /** @@ -225,7 +229,7 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() override fun createIntent(context: Context, input: OAuthUserSignIn): Intent = Intent(context, AuthenticationActivity::class.java).apply { putExtra(KEY_INTENT_EXTRA_URL, input.authorizeUrl) - putExtra("REDIRECT_URI", input.oAuthUserConfiguration.redirectUrl) + putExtra(KEY_INTENT_EXTRA_REDIRECT_URL, input.oAuthUserConfiguration.redirectUrl) putExtra(KEY_INTENT_EXTRA_PRIVATE_BROWSING, input.oAuthUserConfiguration.preferPrivateWebBrowserSession) if (context.isCustomTabsSupportedByDefaultBrowser()) { putExtra(KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER, false) From 7fcd835949366d0ad5ee77a1bdda263655b5f2fe Mon Sep 17 00:00:00 2001 From: Erick Date: Mon, 1 Dec 2025 14:22:39 -0800 Subject: [PATCH 06/11] refactor AuthenticationActivity to simplify redirect URI handling --- .../authentication/AuthenticationActivity.kt | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt index fe29bd2da..0c35dd32f 100644 --- a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt +++ b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt @@ -18,7 +18,6 @@ package com.arcgismaps.toolkit.authentication import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContract @@ -174,9 +173,10 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() */ private fun handleRedirectIntent(intent: Intent?) { val uri = intent?.data - if (isValidRedirectUri(uri)) { + if (uri != null) { + val uriString = uri.toString() val newIntent = Intent().apply { - putExtra(KEY_INTENT_EXTRA_RESPONSE_URI, uri.toString()) + putExtra(KEY_INTENT_EXTRA_RESPONSE_URI, uriString) } setResult(RESULT_CODE_SUCCESS, newIntent) } else { @@ -202,19 +202,6 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() } } - /** - * Validates if the provided URI starts with the expected redirect URI. - * @param uri the URI to validate. - * @return true if the URI is valid, false otherwise. - * @since 300.0.0 - */ - private fun isValidRedirectUri(uri: Uri?): Boolean { - if (uri == null) return false - val expectedRedirectUri = intent.getStringExtra(KEY_INTENT_EXTRA_REDIRECT_URL) ?: "" - val incomingRedirectUri = "${uri.scheme}://${uri.host}" - return incomingRedirectUri == expectedRedirectUri - } - /** * An ActivityResultContract that takes a [OAuthUserSignIn] as input and returns a nullable * string as output. The output string represents a redirect URI as the result of an OAuth user From 1bf954a48ca40ab0b377229ddfe270175398d25a Mon Sep 17 00:00:00 2001 From: Erick Date: Mon, 1 Dec 2025 15:08:02 -0800 Subject: [PATCH 07/11] remove unused imports and logging in Extensions.kt --- .../java/com/arcgismaps/toolkit/authentication/Extensions.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt index 6995d3a0f..be129d039 100644 --- a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt +++ b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt @@ -22,8 +22,6 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.net.Uri -import android.util.Log import androidx.browser.customtabs.CustomTabsClient import androidx.browser.customtabs.CustomTabsIntent import com.arcgismaps.httpcore.authentication.AuthenticationManager @@ -127,7 +125,6 @@ internal fun Context.getPackageThatSupportsCustomTabs(): String? { val packageNames = resolvedActivityList.map { it.activityInfo.packageName } - Log.d("ArcGIS-Main", ".getPackageThatSupportsCustomTabs: $packageNames") return CustomTabsClient.getPackageName(this, packageNames, true) ?: null } From cd36b4b8f5ca611964d3b07dd139f260c92bf683 Mon Sep 17 00:00:00 2001 From: Erick Date: Mon, 1 Dec 2025 16:36:28 -0800 Subject: [PATCH 08/11] refactor AuthenticationActivity to enhance external browser handling and simplify intent management --- ...thenticationActivityExternalBrowserTest.kt | 2 - .../authentication/AuthenticationActivity.kt | 19 +++---- .../toolkit/authentication/Extensions.kt | 57 +++++++++++-------- 3 files changed, 40 insertions(+), 38 deletions(-) diff --git a/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt b/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt index 65dc7e19e..494668226 100644 --- a/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt +++ b/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt @@ -66,7 +66,6 @@ class AuthenticationActivityExternalBrowserTest { val authorizeUrl = "https://example.com/auth" val intent = Intent(ApplicationProvider.getApplicationContext(), AuthenticationActivity::class.java).apply { putExtra(KEY_INTENT_EXTRA_URL, authorizeUrl) - putExtra(KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER, false) } ActivityScenario.launch(intent).use { @@ -95,7 +94,6 @@ class AuthenticationActivityExternalBrowserTest { val authorizeUrl = "https://example.com/auth" val intent = Intent(ApplicationProvider.getApplicationContext(), AuthenticationActivity::class.java).apply { putExtra(KEY_INTENT_EXTRA_URL, authorizeUrl) - putExtra(KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER, true) // Force external browser launch } // AuthenticationActivity will start the external browser intent diff --git a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt index 0c35dd32f..c8f30fa38 100644 --- a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt +++ b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt @@ -25,12 +25,10 @@ import androidx.lifecycle.Lifecycle import com.arcgismaps.httpcore.authentication.OAuthUserSignIn internal const val KEY_INTENT_EXTRA_URL = "KEY_INTENT_EXTRA_URL" -private const val KEY_INTENT_EXTRA_REDIRECT_URL = "KEY_INTENT_EXTRA_REDIRECT_URL" private const val KEY_INTENT_EXTRA_RESPONSE_URI = "KEY_INTENT_EXTRA_RESPONSE_URI" private const val KEY_INTENT_EXTRA_PROMPT_TYPE = "KEY_INTENT_EXTRA_PROMPT_TYPE" private const val KEY_INTENT_EXTRA_PRIVATE_BROWSING = "KEY_INTENT_EXTRA_PRIVATE_BROWSING" private const val KEY_INTENT_EXTRA_IAP_SIGN_OUT_RESPONSE = "KEY_INTENT_EXTRA_IAP_SIGN_OUT_RESPONSE" -internal const val KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER = "KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER" private const val RESULT_CODE_SUCCESS = 1 private const val RESULT_CODE_CANCELED = 2 @@ -192,13 +190,17 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() * @since 300.0.0 */ private fun initiateAuthenticationFlow() = with(intent) { - val shouldLaunchInExternalBrowser = getBooleanExtra(KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER, false) // get the URL to launch or we assume we are redirecting back to the app val url = getStringExtra(KEY_INTENT_EXTRA_URL) ?: return handleRedirectIntent(this) - if (shouldLaunchInExternalBrowser) { + val browserPackageName = getPackageThatSupportsCustomTabs() + if (browserPackageName.isNullOrEmpty()) { launchInExternalBrowser(url) } else { - launchCustomTabs(url, getBooleanExtra(KEY_INTENT_EXTRA_PRIVATE_BROWSING, false)) + launchCustomTabs( + url, + getBooleanExtra(KEY_INTENT_EXTRA_PRIVATE_BROWSING, false), + browserPackageName + ) } } @@ -216,14 +218,7 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() override fun createIntent(context: Context, input: OAuthUserSignIn): Intent = Intent(context, AuthenticationActivity::class.java).apply { putExtra(KEY_INTENT_EXTRA_URL, input.authorizeUrl) - putExtra(KEY_INTENT_EXTRA_REDIRECT_URL, input.oAuthUserConfiguration.redirectUrl) putExtra(KEY_INTENT_EXTRA_PRIVATE_BROWSING, input.oAuthUserConfiguration.preferPrivateWebBrowserSession) - if (context.isCustomTabsSupportedByDefaultBrowser()) { - putExtra(KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER, false) - } else { - // TODO: Pass the package name to launch Custom Tabs with - putExtra(KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER, true) - } } override fun parseResult(resultCode: Int, intent: Intent?): String? { diff --git a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt index be129d039..43031a034 100644 --- a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt +++ b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt @@ -64,10 +64,16 @@ public fun Activity.launchCustomTabs(pendingBrowserAuthenticationChallenge: Brow val (url, preferPrivateWebBrowserSession) = when (pendingBrowserAuthenticationChallenge) { is BrowserAuthenticationChallenge.OAuthUserSignIn -> pendingBrowserAuthenticationChallenge.oAuthUserSignIn.authorizeUrl to pendingBrowserAuthenticationChallenge.oAuthUserSignIn.oAuthUserConfiguration.preferPrivateWebBrowserSession + is BrowserAuthenticationChallenge.IapSignIn -> pendingBrowserAuthenticationChallenge.iapSignIn.authorizeUrl to false is BrowserAuthenticationChallenge.IapSignOut -> pendingBrowserAuthenticationChallenge.iapSignOut.signOutUrl to false } - launchCustomTabs(url, preferPrivateWebBrowserSession) + val preferredBrowserPackageName = this.getPackageThatSupportsCustomTabs() + if (!preferredBrowserPackageName.isNullOrEmpty()) { + launchCustomTabs(url, preferPrivateWebBrowserSession, preferredBrowserPackageName) + } else { + launchInExternalBrowser(url) + } } @@ -80,12 +86,18 @@ public fun Activity.launchCustomTabs(pendingBrowserAuthenticationChallenge: Brow * * @since 200.8.1 */ -internal fun Activity.launchCustomTabs(authorizeUrl: String, preferPrivateWebBrowserSession: Boolean?) { - CustomTabsIntent.Builder().apply { - if (preferPrivateWebBrowserSession == true) { - setEphemeralBrowsingEnabled(true) - } - }.build().launchUrl(this, authorizeUrl.toUri()) +internal fun Activity.launchCustomTabs( + authorizeUrl: String, + preferPrivateWebBrowserSession: Boolean?, + preferredBrowserPackageName: String +) { + val builder = CustomTabsIntent.Builder() + if (preferPrivateWebBrowserSession == true) { + builder.setEphemeralBrowsingEnabled(true) + } + val customTabsIntent = builder.build() + customTabsIntent.intent.setPackage(preferredBrowserPackageName) + customTabsIntent.launchUrl(this, authorizeUrl.toUri()) } /** @@ -103,28 +115,25 @@ internal fun Activity.launchInExternalBrowser(authorizeUrl: String) { startActivity(intent) } -/** - * Returns true if there is at least one browser on the device that supports Custom Tabs. - * @since 300.0.0 - */ -internal fun Context.isCustomTabsSupportedByDefaultBrowser(): Boolean { - // first check if the default browser supports Custom Tabs - val packageName = CustomTabsClient.getPackageName(this, emptyList()) - return packageName != null -} - /** * Returns the package name of a browser that supports Custom Tabs, or null if none is found. * @since 300.0.0 */ internal fun Context.getPackageThatSupportsCustomTabs(): String? { - val pm = packageManager - val activityIntent = Intent(Intent.ACTION_VIEW, "http://www.example.com".toUri()) - val resolvedActivityList = pm.queryIntentActivities(activityIntent, PackageManager.MATCH_ALL) + // Check if the default browser supports Custom Tabs + val defaultBrowser = CustomTabsClient.getPackageName(this, emptyList()) + return if (!defaultBrowser.isNullOrEmpty()) { + defaultBrowser + } else { + // If not, check all browsers that can handle http intents + val packageManager = packageManager + val activityIntent = Intent(Intent.ACTION_VIEW, "http://www.example.com".toUri()) + val resolvedActivityList = packageManager.queryIntentActivities(activityIntent, PackageManager.MATCH_ALL) - val packageNames = resolvedActivityList.map { - it.activityInfo.packageName - } + val packageNames = resolvedActivityList.map { + it.activityInfo.packageName + } - return CustomTabsClient.getPackageName(this, packageNames, true) ?: null + CustomTabsClient.getPackageName(this, packageNames, true) + } } From e3b496935dcf85f6e1603b88d1d7959da633b468 Mon Sep 17 00:00:00 2001 From: Erick Date: Tue, 2 Dec 2025 17:11:17 -0800 Subject: [PATCH 09/11] adds flag to allow external browser to AuthenticationActivity and handles it --- .../authentication/AuthenticationActivity.kt | 79 ++++++++++++------- .../authentication/OAuthAuthenticator.kt | 24 ++++-- 2 files changed, 68 insertions(+), 35 deletions(-) diff --git a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt index c8f30fa38..283d44f67 100644 --- a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt +++ b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt @@ -29,11 +29,14 @@ private const val KEY_INTENT_EXTRA_RESPONSE_URI = "KEY_INTENT_EXTRA_RESPONSE_URI private const val KEY_INTENT_EXTRA_PROMPT_TYPE = "KEY_INTENT_EXTRA_PROMPT_TYPE" private const val KEY_INTENT_EXTRA_PRIVATE_BROWSING = "KEY_INTENT_EXTRA_PRIVATE_BROWSING" private const val KEY_INTENT_EXTRA_IAP_SIGN_OUT_RESPONSE = "KEY_INTENT_EXTRA_IAP_SIGN_OUT_RESPONSE" +internal const val KEY_INTENT_EXTRA_ALLOW_LAUNCH_ON_EXTERNAL_BROWSER = "KEY_INTENT_EXTRA_ALLOW_LAUNCH_ON_EXTERNAL_BROWSER" +private const val KEY_INTENT_EXTRA_EXCEPTION_MESSAGE = "KEY_INTENT_EXTRA_EXCEPTION_MESSAGE" private const val RESULT_CODE_SUCCESS = 1 private const val RESULT_CODE_CANCELED = 2 private const val VALUE_INTENT_EXTRA_PROMPT_TYPE_SIGN_OUT = "SIGN_OUT" +private const val NO_CUSTOM_TABS_BROWSER_AVAILABLE_ERROR_MESSAGE = "No browser that supports Custom Tabs is available on this device." /** * Handles OAuth sign-in and Identity-Aware Proxy (IAP) sign-in/sign-out flows by launching @@ -163,26 +166,6 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() handleRedirectIntent(intent) } - /** - * Finishes this activity with a response containing a success code and the redirect intent's uri - * or a canceled code if no uri can be found. - * - * @since 200.8.0 - */ - private fun handleRedirectIntent(intent: Intent?) { - val uri = intent?.data - if (uri != null) { - val uriString = uri.toString() - val newIntent = Intent().apply { - putExtra(KEY_INTENT_EXTRA_RESPONSE_URI, uriString) - } - setResult(RESULT_CODE_SUCCESS, newIntent) - } else { - setResult(RESULT_CODE_CANCELED) - } - finish() - } - /** * Handles the authentication challenge by launching either an external browser or a Custom Tab * based on the intent extras and provided URL. @@ -192,16 +175,39 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() private fun initiateAuthenticationFlow() = with(intent) { // get the URL to launch or we assume we are redirecting back to the app val url = getStringExtra(KEY_INTENT_EXTRA_URL) ?: return handleRedirectIntent(this) + val allowExternalBrowser = getBooleanExtra(KEY_INTENT_EXTRA_ALLOW_LAUNCH_ON_EXTERNAL_BROWSER, true) val browserPackageName = getPackageThatSupportsCustomTabs() - if (browserPackageName.isNullOrEmpty()) { - launchInExternalBrowser(url) - } else { - launchCustomTabs( + when { + browserPackageName != null -> launchCustomTabs( url, getBooleanExtra(KEY_INTENT_EXTRA_PRIVATE_BROWSING, false), browserPackageName ) + + allowExternalBrowser -> launchInExternalBrowser(url) + else -> handleRedirectIntent(intent = null, errorMessage = NO_CUSTOM_TABS_BROWSER_AVAILABLE_ERROR_MESSAGE) + } + } + + /** + * Finishes this activity with a response containing a success code and the redirect intent's uri + * or a canceled code if no uri can be found. + * + * @since 200.8.0 + */ + private fun handleRedirectIntent(intent: Intent?, errorMessage: String? = null) { + val uri = intent?.data + val newIntent = Intent() + if (uri != null) { + newIntent.putExtra(KEY_INTENT_EXTRA_RESPONSE_URI, uri.toString()) + setResult(RESULT_CODE_SUCCESS, newIntent) + } else { + errorMessage?.let { + newIntent.putExtra(KEY_INTENT_EXTRA_EXCEPTION_MESSAGE, it) + } + setResult(RESULT_CODE_CANCELED, newIntent) } + finish() } /** @@ -214,18 +220,21 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() * * @since 200.8.0 */ - internal class OAuthUserSignInContract : ActivityResultContract() { + internal class OAuthUserSignInContract : ActivityResultContract() { override fun createIntent(context: Context, input: OAuthUserSignIn): Intent = Intent(context, AuthenticationActivity::class.java).apply { putExtra(KEY_INTENT_EXTRA_URL, input.authorizeUrl) putExtra(KEY_INTENT_EXTRA_PRIVATE_BROWSING, input.oAuthUserConfiguration.preferPrivateWebBrowserSession) + putExtra(KEY_INTENT_EXTRA_ALLOW_LAUNCH_ON_EXTERNAL_BROWSER, input.allowExternalBrowserLaunch) } - override fun parseResult(resultCode: Int, intent: Intent?): String? { - return if (resultCode == RESULT_CODE_SUCCESS) { - intent?.getStringExtra(KEY_INTENT_EXTRA_RESPONSE_URI) - } else { - null + override fun parseResult(resultCode: Int, intent: Intent?): OAuthUserSignInResult { + val redirectUrl = intent?.getStringExtra(KEY_INTENT_EXTRA_RESPONSE_URI) + val errorMessage = intent?.getStringExtra(KEY_INTENT_EXTRA_EXCEPTION_MESSAGE) + return when { + resultCode == RESULT_CODE_SUCCESS && redirectUrl != null -> OAuthUserSignInResult.Success(redirectUrl) + errorMessage != null -> OAuthUserSignInResult.Failure(IllegalStateException(errorMessage)) + else -> OAuthUserSignInResult.Canceled } } } @@ -277,4 +286,14 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() false } } + + /** + * Represents the result of an OAuth sign-in operation. + * @since 300.0.0 + */ + internal sealed class OAuthUserSignInResult { + data class Success(val redirectUri: String) : OAuthUserSignInResult() + data class Failure(val exception: Exception) : OAuthUserSignInResult() + data object Canceled : OAuthUserSignInResult() + } } diff --git a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/OAuthAuthenticator.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/OAuthAuthenticator.kt index 7eb355726..9e106a856 100644 --- a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/OAuthAuthenticator.kt +++ b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/OAuthAuthenticator.kt @@ -61,11 +61,17 @@ internal fun OAuthAuthenticator( // composition and a new `didLaunch` state variable will be initialized again to `false`. var didLaunch by rememberSaveable(oAuthPendingSignIn.hashCode().toString()) { mutableStateOf(false) } val launcher = - rememberLauncherForActivityResult(contract = AuthenticationActivity.OAuthUserSignInContract()) { redirectUrl -> - redirectUrl?.let { - oAuthPendingSignIn.complete(redirectUrl) - } ?: run { - oAuthPendingSignIn.cancel() + rememberLauncherForActivityResult(contract = AuthenticationActivity.OAuthUserSignInContract()) { signInResult -> + when (signInResult) { + is AuthenticationActivity.OAuthUserSignInResult.Success -> { + oAuthPendingSignIn.complete(signInResult.redirectUri) + } + is AuthenticationActivity.OAuthUserSignInResult.Failure -> { + oAuthPendingSignIn.cancel(signInResult.exception) + } + is AuthenticationActivity.OAuthUserSignInResult.Canceled -> { + oAuthPendingSignIn.cancel() + } } } // Launching an activity is a side effect. We don't need `LaunchedEffect` because this is not suspending @@ -85,3 +91,11 @@ internal fun OAuthAuthenticator( } } } + +//TODO: This is a temporary backing property until we have a better way to configure this behavior. +internal var _allowExternalBrowserLaunch: Boolean = true +public var OAuthUserSignIn.allowExternalBrowserLaunch: Boolean + get() = _allowExternalBrowserLaunch + set(value) { + _allowExternalBrowserLaunch = value + } From 7108bf0932489aeff643a0779fe83ea527753d5d Mon Sep 17 00:00:00 2001 From: Erick Date: Tue, 2 Dec 2025 17:18:32 -0800 Subject: [PATCH 10/11] adds tests to verify behavior --- ...thenticationActivityExternalBrowserTest.kt | 49 ++++++++++++++++--- .../authentication/AuthenticationActivity.kt | 4 +- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt b/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt index 494668226..c66aab077 100644 --- a/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt +++ b/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt @@ -28,6 +28,9 @@ import androidx.test.espresso.intent.matcher.IntentMatchers.hasCategories import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtraWithKey import androidx.test.core.app.ActivityScenario import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.not import org.junit.After @@ -50,19 +53,23 @@ class AuthenticationActivityExternalBrowserTest { @After fun tearDown() { + unmockkAll() Intents.release() } /** - * Given [AuthenticationActivity] is launched with an intent containing the flag KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER - * set to false + * Given [AuthenticationActivity] with a the browser "com.android.chrome" that supports Custom Tabs * When [AuthenticationActivity] starts * Then an intent should be fired with ACTION_VIEW, simulating a Custom Tabs launch * And the intent contains the Custom Tabs session extra key * @since 300.0.0 */ @Test - fun launchesCustomTabsWhenExternalBrowserFlagIsFalse() { + fun launchesCustomTabsWhenSupported() { + // Mock the top-level extension so it returns "com.android.chrome" (supports Custom Tabs) + mockkStatic("com.arcgismaps.toolkit.authentication.ExtensionsKt") + every { any().getPackageThatSupportsCustomTabs() } returns "com.android.chrome" + val authorizeUrl = "https://example.com/auth" val intent = Intent(ApplicationProvider.getApplicationContext(), AuthenticationActivity::class.java).apply { putExtra(KEY_INTENT_EXTRA_URL, authorizeUrl) @@ -82,8 +89,7 @@ class AuthenticationActivityExternalBrowserTest { } /** - * Given [AuthenticationActivity] is launched with an intent containing the flag KEY_INTENT_EXTRA_LAUNCH_IN_EXTERNAL_BROWSER - * set to true + * Given [AuthenticationActivity] is launched with no browser that support Custom Tabs * When [AuthenticationActivity] starts * Then an intent should be fired with ACTION_VIEW, simulating an external browser launch * And the intent does not contain the Custom Tabs session extra key @@ -91,6 +97,10 @@ class AuthenticationActivityExternalBrowserTest { */ @Test fun launchesExternalBrowserWhenCustomTabsNotSupported() { + // Mock the top-level extension so it returns null and simulates no browsers that support Custom Tabs + mockkStatic("com.arcgismaps.toolkit.authentication.ExtensionsKt") + every { any().getPackageThatSupportsCustomTabs() } returns null + val authorizeUrl = "https://example.com/auth" val intent = Intent(ApplicationProvider.getApplicationContext(), AuthenticationActivity::class.java).apply { putExtra(KEY_INTENT_EXTRA_URL, authorizeUrl) @@ -110,6 +120,34 @@ class AuthenticationActivityExternalBrowserTest { } } + /** + * Given [AuthenticationActivity] is launched with no browser that support Custom Tabs + * And launching external browsers is disallowed + * When [AuthenticationActivity] starts + * Then the activity finishes with RESULT_CODE_CANCELED and includes an exception message in the result data + * @since 300.0.0 + */ + @Test + fun returnsExceptionWhenNoBrowserAvailable() { + // Mock the top-level extension so it returns null and simulates no browsers that support Custom Tabs + mockkStatic("com.arcgismaps.toolkit.authentication.ExtensionsKt") + every { any().getPackageThatSupportsCustomTabs() } returns null + + val authorizeUrl = "https://example.com/auth" + val intent = Intent(ApplicationProvider.getApplicationContext(), AuthenticationActivity::class.java).apply { + putExtra(KEY_INTENT_EXTRA_URL, authorizeUrl) + putExtra(KEY_INTENT_EXTRA_ALLOW_LAUNCH_ON_EXTERNAL_BROWSER, false) + } + + // invoke the activity + val scenario = ActivityScenario.launchActivityForResult(intent) + scenario.close() + val result = scenario.result + assertThat(result.resultCode).isEqualTo(2) + val exceptionMessage = result.resultData?.getStringExtra(KEY_INTENT_EXTRA_EXCEPTION_MESSAGE) + assertThat(exceptionMessage).isEqualTo(NO_CUSTOM_TABS_BROWSER_AVAILABLE_ERROR_MESSAGE) + } + /** * Given [AuthenticationActivity] is launched with an intent containing a valid redirect URI * When the activity starts @@ -133,4 +171,3 @@ class AuthenticationActivityExternalBrowserTest { assertThat(result.resultData?.getStringExtra("KEY_INTENT_EXTRA_RESPONSE_URI")).isEqualTo(redirectUri) } } - diff --git a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt index 283d44f67..a3795b4e5 100644 --- a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt +++ b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt @@ -30,13 +30,13 @@ private const val KEY_INTENT_EXTRA_PROMPT_TYPE = "KEY_INTENT_EXTRA_PROMPT_TYPE" private const val KEY_INTENT_EXTRA_PRIVATE_BROWSING = "KEY_INTENT_EXTRA_PRIVATE_BROWSING" private const val KEY_INTENT_EXTRA_IAP_SIGN_OUT_RESPONSE = "KEY_INTENT_EXTRA_IAP_SIGN_OUT_RESPONSE" internal const val KEY_INTENT_EXTRA_ALLOW_LAUNCH_ON_EXTERNAL_BROWSER = "KEY_INTENT_EXTRA_ALLOW_LAUNCH_ON_EXTERNAL_BROWSER" -private const val KEY_INTENT_EXTRA_EXCEPTION_MESSAGE = "KEY_INTENT_EXTRA_EXCEPTION_MESSAGE" +internal const val KEY_INTENT_EXTRA_EXCEPTION_MESSAGE = "KEY_INTENT_EXTRA_EXCEPTION_MESSAGE" private const val RESULT_CODE_SUCCESS = 1 private const val RESULT_CODE_CANCELED = 2 private const val VALUE_INTENT_EXTRA_PROMPT_TYPE_SIGN_OUT = "SIGN_OUT" -private const val NO_CUSTOM_TABS_BROWSER_AVAILABLE_ERROR_MESSAGE = "No browser that supports Custom Tabs is available on this device." +internal const val NO_CUSTOM_TABS_BROWSER_AVAILABLE_ERROR_MESSAGE = "No browser that supports Custom Tabs is available on this device." /** * Handles OAuth sign-in and Identity-Aware Proxy (IAP) sign-in/sign-out flows by launching From 1da753cf3ed1d6a0c73866ca78efc3a0a93d1db6 Mon Sep 17 00:00:00 2001 From: Erick Date: Tue, 2 Dec 2025 17:29:17 -0800 Subject: [PATCH 11/11] adds a few TODOs and cleans up test --- .../AuthenticationActivityExternalBrowserTest.kt | 6 +++--- .../toolkit/authentication/AuthenticationActivity.kt | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt b/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt index c66aab077..4c2ef461f 100644 --- a/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt +++ b/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt @@ -143,7 +143,7 @@ class AuthenticationActivityExternalBrowserTest { val scenario = ActivityScenario.launchActivityForResult(intent) scenario.close() val result = scenario.result - assertThat(result.resultCode).isEqualTo(2) + assertThat(result.resultCode).isEqualTo(RESULT_CODE_CANCELED) val exceptionMessage = result.resultData?.getStringExtra(KEY_INTENT_EXTRA_EXCEPTION_MESSAGE) assertThat(exceptionMessage).isEqualTo(NO_CUSTOM_TABS_BROWSER_AVAILABLE_ERROR_MESSAGE) } @@ -151,7 +151,7 @@ class AuthenticationActivityExternalBrowserTest { /** * Given [AuthenticationActivity] is launched with an intent containing a valid redirect URI * When the activity starts - * Then the activity finishes with RESULT_CODE_SUCCESS and includes the the redirect URI in the result data + * Then the activity finishes with RESULT_CODE_SUCCESS and includes the redirect URI in the result data * @since 300.0.0 */ @Test @@ -167,7 +167,7 @@ class AuthenticationActivityExternalBrowserTest { val scenario = ActivityScenario.launchActivityForResult(intent) scenario.close() val result = scenario.result - assertThat(result.resultCode).isEqualTo(1) + assertThat(result.resultCode).isEqualTo(RESULT_CODE_SUCCESS) assertThat(result.resultData?.getStringExtra("KEY_INTENT_EXTRA_RESPONSE_URI")).isEqualTo(redirectUri) } } diff --git a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt index a3795b4e5..f2b0f3175 100644 --- a/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt +++ b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/AuthenticationActivity.kt @@ -32,8 +32,8 @@ private const val KEY_INTENT_EXTRA_IAP_SIGN_OUT_RESPONSE = "KEY_INTENT_EXTRA_IAP internal const val KEY_INTENT_EXTRA_ALLOW_LAUNCH_ON_EXTERNAL_BROWSER = "KEY_INTENT_EXTRA_ALLOW_LAUNCH_ON_EXTERNAL_BROWSER" internal const val KEY_INTENT_EXTRA_EXCEPTION_MESSAGE = "KEY_INTENT_EXTRA_EXCEPTION_MESSAGE" -private const val RESULT_CODE_SUCCESS = 1 -private const val RESULT_CODE_CANCELED = 2 +internal const val RESULT_CODE_SUCCESS = 1 +internal const val RESULT_CODE_CANCELED = 2 private const val VALUE_INTENT_EXTRA_PROMPT_TYPE_SIGN_OUT = "SIGN_OUT" internal const val NO_CUSTOM_TABS_BROWSER_AVAILABLE_ERROR_MESSAGE = "No browser that supports Custom Tabs is available on this device." @@ -123,6 +123,7 @@ internal const val NO_CUSTOM_TABS_BROWSER_AVAILABLE_ERROR_MESSAGE = "No browser * } * ``` * See [README.md](../README.md) for more details. + * //TODO: Update doc to mention external browser launch. * @since 200.8.0 */ public class AuthenticationActivity internal constructor() : ComponentActivity() { @@ -253,6 +254,7 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() override fun createIntent(context: Context, input: String): Intent = Intent(context, AuthenticationActivity::class.java).apply { putExtra(KEY_INTENT_EXTRA_URL, input) + // TODO: Allow external browser launch configuration for IAP sign-in. } override fun parseResult(resultCode: Int, intent: Intent?): String? {