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..4c2ef461f --- /dev/null +++ b/toolkit/authentication/src/androidTest/java/com/arcgismaps/toolkit/authentication/AuthenticationActivityExternalBrowserTest.kt @@ -0,0 +1,173 @@ +/* + * 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 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.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.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 +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumentation test to verify [AuthenticationActivity]'s behavior. + * + * @since 300.0.0 + */ +@RunWith(AndroidJUnit4::class) +class AuthenticationActivityExternalBrowserTest { + + @Before + fun setUp() { + Intents.init() + } + + @After + fun tearDown() { + unmockkAll() + Intents.release() + } + + /** + * 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 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) + } + + 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 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 + * @since 300.0.0 + */ + @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) + } + + // AuthenticationActivity will start the external browser intent + ActivityScenario.launch(intent).use { + 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 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(RESULT_CODE_CANCELED) + 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 + * Then the activity finishes with RESULT_CODE_SUCCESS and includes 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(RESULT_CODE_SUCCESS) + 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 077a46482..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 @@ -24,16 +24,19 @@ 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" +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." /** * Handles OAuth sign-in and Identity-Aware Proxy (IAP) sign-in/sign-out flows by launching @@ -120,17 +123,14 @@ private const val VALUE_INTENT_EXTRA_PROMPT_TYPE_SIGN_OUT = "SIGN_OUT" * } * ``` * 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() { 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 +138,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. @@ -154,7 +153,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() @@ -168,22 +167,46 @@ public class AuthenticationActivity internal constructor() : ComponentActivity() handleRedirectIntent(intent) } + /** + * 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) { + // 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() + 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?) { + private fun handleRedirectIntent(intent: Intent?, errorMessage: String? = null) { val uri = intent?.data + val newIntent = Intent() if (uri != null) { - val uriString = uri.toString() - val newIntent = Intent().apply { - putExtra(KEY_INTENT_EXTRA_RESPONSE_URI, uriString) - } + newIntent.putExtra(KEY_INTENT_EXTRA_RESPONSE_URI, uri.toString()) setResult(RESULT_CODE_SUCCESS, newIntent) } else { - setResult(RESULT_CODE_CANCELED) + errorMessage?.let { + newIntent.putExtra(KEY_INTENT_EXTRA_EXCEPTION_MESSAGE, it) + } + setResult(RESULT_CODE_CANCELED, newIntent) } finish() } @@ -198,18 +221,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 } } } @@ -228,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? { @@ -261,4 +288,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/Extensions.kt b/toolkit/authentication/src/main/java/com/arcgismaps/toolkit/authentication/Extensions.kt index 90ee02cab..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 @@ -19,11 +19,13 @@ package com.arcgismaps.toolkit.authentication import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.browser.customtabs.CustomTabsClient import androidx.browser.customtabs.CustomTabsIntent 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 /** @@ -62,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) + } } @@ -78,10 +86,54 @@ 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) +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()) +} + +/** + * 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) +} + +/** + * 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? { + // 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 } - }.build().launchUrl(this, authorizeUrl.toUri()) + + CustomTabsClient.getPackageName(this, packageNames, true) + } } 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 + }