Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions toolkit/authentication/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,5 @@ dependencies {
// uiautomator
androidTestImplementation(libs.androidx.uiautomator)
androidTestImplementation(libs.mockk.android)
androidTestImplementation(libs.androidx.test.espresso.intents)
}
Original file line number Diff line number Diff line change
@@ -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<android.content.Context>().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<AuthenticationActivity>(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<android.content.Context>().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<AuthenticationActivity>(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<android.content.Context>().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<AuthenticationActivity>(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<AuthenticationActivity>(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)
}
}
7 changes: 7 additions & 0 deletions toolkit/authentication/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,12 @@
-->

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- This is needed to check for Custom Tabs support on the device -->
<queries>
<intent>
<action android:name=
"android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -120,27 +123,23 @@ 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.
override fun onWindowFocusChanged(hasFocus: Boolean) {
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.
Expand All @@ -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()
Expand All @@ -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()
}
Expand All @@ -198,18 +221,21 @@ public class AuthenticationActivity internal constructor() : ComponentActivity()
*
* @since 200.8.0
*/
internal class OAuthUserSignInContract : ActivityResultContract<OAuthUserSignIn, String?>() {
internal class OAuthUserSignInContract : ActivityResultContract<OAuthUserSignIn, OAuthUserSignInResult>() {
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
}
}
}
Expand All @@ -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? {
Expand Down Expand Up @@ -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()
}
}
Loading