Skip to content
Merged
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 @@ -97,6 +97,7 @@ truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
dokka-versioning = { group = "org.jetbrains.dokka", name = "versioning-plugin", version.ref = "dokka" }
arcore = { group = "com.google.ar", name = "core", version.ref = "arcore" }
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "androidxEspresso" }

[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
Expand Down
4 changes: 4 additions & 0 deletions toolkit/authentication/api/authentication.api
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ public final class com/arcgismaps/toolkit/authentication/ClientCertificateChalle
public fun toString ()Ljava/lang/String;
}

public final class com/arcgismaps/toolkit/authentication/CustomTabsNotFoundException : java/lang/Exception {
public static final field $stable I
}

public final class com/arcgismaps/toolkit/authentication/ExtensionsKt {
public static final fun launchCustomTabs (Landroid/app/Activity;Lcom/arcgismaps/httpcore/authentication/OAuthUserSignIn;)V
public static final fun launchCustomTabs (Landroid/app/Activity;Lcom/arcgismaps/toolkit/authentication/BrowserAuthenticationChallenge;)V
Expand Down
3 changes: 3 additions & 0 deletions toolkit/authentication/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,8 @@ dependencies {

// uiautomator
androidTestImplementation(libs.androidx.uiautomator)
// espresso intents
androidTestImplementation(libs.androidx.espresso.intents)
// mockk for android tests
androidTestImplementation(libs.mockk.android)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* 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.ActivityScenario
import androidx.test.core.app.ApplicationProvider
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.hasExtraWithKey
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.junit.After
import org.junit.Before
import org.junit.Test

/**
* Instrumentation test to verify [AuthenticationActivity]'s behavior.
*
* @since 300.0.0
*/
class AuthenticationActivityTest {

@Before
fun setUp() {
Intents.init()
}

@After
fun tearDown() {
unmockkAll()
Intents.release()
}

/**
* Given a device with default browser that does support Custom Tabs,
* When [AuthenticationActivity] starts due to receiving an intent for OAuth sign-in,
* Then the activity launches an outgoing intent to launch Custom Tabs. The outgoing
* intent's action should be `ACTION_VIEW`, contain data with authorize URL as well as extra data
* with Custom Tabs session package.
* @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>().canDefaultBrowserLaunchCustomTabs() } returns true

// Define the authorize URL to be used in the intent
val authorizeUrl = "https://example.com/auth"
val intent = Intent(ApplicationProvider.getApplicationContext(), AuthenticationActivity::class.java).apply {
putExtra(KEY_INTENT_EXTRA_URL, authorizeUrl)
}
// Launch the AuthenticationActivity with the intent for OAuth sign in
ActivityScenario.launch<AuthenticationActivity>(intent).use {
// Verify that an intent to launch Custom Tabs was sent by AuthenticationActivity
intended(
allOf(
// Verify that the launched intent is a Custom Tabs intent with the expected properties
hasAction(Intent.ACTION_VIEW),
hasData(authorizeUrl),
// Custom Tabs adds android.support.customtabs.extra.SESSION
hasExtraWithKey("android.support.customtabs.extra.SESSION")

)
)
}
}

/**
* Given a device with default browser that does not support Custom Tabs
* When [AuthenticationActivity] starts due to receiving an intent for OAuth sign-in,
* Then the activity finishes with RESULT_CODE_CANCELED and includes an exception message in the result data
* @since 300.0.0
*/
@Test
fun returnsExceptionWhenNoCustomTabsAvailable() {
// 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>().canDefaultBrowserLaunchCustomTabs() } returns false

val authorizeUrl = "https://example.com/auth"
val intent = Intent(ApplicationProvider.getApplicationContext(), AuthenticationActivity::class.java).apply {
putExtra(KEY_INTENT_EXTRA_URL, authorizeUrl)
}

// Launch the AuthenticationActivity with the intent for OAuth sign in
ActivityScenario.launchActivityForResult<AuthenticationActivity>(intent).use { activityScenario ->
assertThat(activityScenario.result.resultCode).isEqualTo(RESULT_CODE_CANCELED)
val exceptionMessage = activityScenario.result.resultData?.getStringExtra(KEY_INTENT_EXTRA_EXCEPTION_MESSAGE)
assertThat(exceptionMessage).isEqualTo(VALUE_INTENT_EXTRA_EXCEPTION_MESSAGE_NO_CUSTOM_TAB)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,14 @@ import androidx.lifecycle.ViewModel
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import com.arcgismaps.ArcGISEnvironment
import com.arcgismaps.exceptions.OperationCancelledException
import com.arcgismaps.httpcore.authentication.ArcGISAuthenticationChallenge
import com.arcgismaps.httpcore.authentication.ArcGISAuthenticationChallengeResponse
import com.arcgismaps.httpcore.authentication.OAuthUserConfiguration
import com.arcgismaps.httpcore.authentication.OAuthUserCredential
import com.google.common.truth.Truth.assertThat
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
Expand Down Expand Up @@ -62,7 +65,10 @@ class BrowserUserLauncherTests {
fun signOutBefore() = signOut()

@After
fun signOutAfter() = signOut()
fun tearDown() {
signOut()
unmockkAll()
}

private fun signOut() {
runBlocking {
Expand Down Expand Up @@ -107,6 +113,39 @@ class BrowserUserLauncherTests {
)
}

/**
* Given a device with a default browser that does not support Custom Tabs,
* When the Authenticator receives an [ArcGISAuthenticationChallenge] for OAuth sign in,
* Then the challenge will fail with a [CustomTabsNotFoundException].
*
* @since 200.8.0
*/
@Test
fun oAuthSignInNoCustomTabs() = runTest {
// 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>().canDefaultBrowserLaunchCustomTabs() } returns false

// configure the ArcGISHttpClient to intercept token requests and return a fake token response
// this is necessary when we are faking a successful sign-in without entering credentials,
// as we are not given a valid token from the OAuth server and RTC won't be able to verify it.
ArcGISEnvironment.configureArcGISHttpClient {
setupOAuthTokenRequestInterceptor()
}
val response = testOAuthChallengeWithStateRestoration(
waitForBrowser = false
) {
// No user interaction, as the Custom Tabs cannot be launched
}.await().getOrThrow()

assertThat(response).isInstanceOf(
ArcGISAuthenticationChallengeResponse.ContinueAndFailWithError::class.java
)
assertThat((response as ArcGISAuthenticationChallengeResponse.ContinueAndFailWithError).error).isInstanceOf(
CustomTabsNotFoundException::class.java
)
}

/**
* Given an [AuthenticatorState] configured with an [OAuthUserConfiguration] for ArcGIS Online,
* When an [ArcGISAuthenticationChallenge] is received and the user cancels the sign in process,
Expand All @@ -118,8 +157,9 @@ class BrowserUserLauncherTests {
fun cancelSignIn() = runTest {
val response = testOAuthChallengeWithStateRestoration {
clickByText("Cancel")
}.await().exceptionOrNull()
assert(response is OperationCancelledException)
}.await().getOrThrow()

assertThat(response).isInstanceOf(ArcGISAuthenticationChallengeResponse.Cancel::class.java)
}

/**
Expand All @@ -133,20 +173,24 @@ class BrowserUserLauncherTests {
fun pressBack() = runTest {
val response = testOAuthChallengeWithStateRestoration {
pressBack()
}.await().exceptionOrNull()
assert(response is OperationCancelledException)
}.await().getOrThrow()

assertThat(response).isInstanceOf(ArcGISAuthenticationChallengeResponse.Cancel::class.java)
}

/**
* Places an [Authenticator] in the composition and issues an OAuth challenge.
* Once the browser is launched, [userInputOnDialog] will be called to simulate user input.
* Also, the device will be rotated to ensure that the Authenticator can handle configuration
* changes before calling [userInputOnDialog].
* [waitForBrowser] can be set to false to skip waiting for the browser to launch. This is useful
* for tests that end up never launching the browser, such as when no Custom Tabs scenario is simulated.
*
* @since 200.8.0
*/
@OptIn(ExperimentalCoroutinesApi::class)
private fun TestScope.testOAuthChallengeWithStateRestoration(
waitForBrowser: Boolean = true,
userInputOnDialog: UiDevice.() -> Unit,
): Deferred<Result<ArcGISAuthenticationChallengeResponse>> {
// start the activity (which contains the Authenticator)
Expand All @@ -173,7 +217,9 @@ class BrowserUserLauncherTests {
val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())

// wait for the browser to be launched
uiDevice.awaitViewVisible("com.android.chrome")
if (waitForBrowser) {
uiDevice.awaitViewVisible("com.android.chrome")
}

// rotate the device to ensure the Authenticator can handle configuration changes
uiDevice.setOrientationLandscape()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import com.google.common.truth.Truth.assertThat
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import org.junit.After
import org.junit.Rule
import org.junit.Test

Expand All @@ -33,6 +37,12 @@ class IapAuthenticatorTest {
@get:Rule
val composeTestRule = createComposeRule()

@After
fun tearDown() {
// Unmock all mocked static methods after each test
unmockkAll()
}

/**
* Given an [IapSignInAuthenticator] composable,
* When it is launched with a valid URL,
Expand All @@ -54,7 +64,7 @@ class IapAuthenticatorTest {
IapSignInAuthenticator(
authorizeUrl = "https://www.arcgis.com/index.html",
onComplete = { receivedRedirectUri = it },
onCancel = { cancellationHandled= true }
onCancel = { cancellationHandled = true }
)
}

Expand All @@ -69,6 +79,41 @@ class IapAuthenticatorTest {
assertThat(receivedRedirectUri).contains(expectedRedirectUri)
}

/**
* Given an [IapSignInAuthenticator] composable,
* When it is launched on a device with no Custom Tab support,
* Then it should not launch a Custom Tab
* And it should handle the absence of Custom Tabs by invoking the cancellation callback with an exception.
*
* @since 300.0.0
*/
@Test
fun verifyIapAuthenticatorCallsOnCancelWhenNoCustomTabIsAvailable() {
// Mock the top-level extension so it returns null (no Custom Tab available)
mockkStatic("com.arcgismaps.toolkit.authentication.ExtensionsKt")
every { any<android.content.Context>().canDefaultBrowserLaunchCustomTabs() } returns false

var receivedRedirectUri: String? = null
var cancellationException: Exception? = null
val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
composeTestRule.setContent {
IapSignInAuthenticator(
authorizeUrl = "https://www.arcgis.com/index.html",
onComplete = { receivedRedirectUri = it },
onCancel = { cancellationException = it }
)
}

composeTestRule.waitForIdle()
// Verify that the Custom Tab is not launched
val isCustomTabLaunched = uiDevice.waitForWindowUpdate("com.android.chrome", 2000)
// Since no Custom Tab is available, it should not be launched
assertThat(isCustomTabLaunched).isFalse()
// Verify that cancellation was handled with an exception
assertThat(cancellationException).isInstanceOf(CustomTabsNotFoundException::class.java)
assertThat(receivedRedirectUri).isNull()
}


/**
* Given an [IapSignInAuthenticator] composable,
Expand All @@ -82,12 +127,16 @@ class IapAuthenticatorTest {
fun verifyIapAuthenticatorHandlesCancellation() {
var onCompleteCalled = false
var cancellationHandled = false
var signOutException: Exception? = null
val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
composeTestRule.setContent {
IapSignInAuthenticator(
authorizeUrl = "https://www.arcgis.com/index.html",
onComplete = { onCompleteCalled = true },
onCancel = { cancellationHandled = true }
onCancel = {
cancellationHandled = true
signOutException = it
}
)
}

Expand All @@ -97,6 +146,7 @@ class IapAuthenticatorTest {
composeTestRule.waitForIdle()
// Verify that the cancellation was handled
assertThat(cancellationHandled).isTrue()
assertThat(signOutException).isNull()
assertThat(onCompleteCalled).isFalse()
}

Expand All @@ -114,13 +164,17 @@ class IapAuthenticatorTest {
fun verifyIapSignOutAuthenticatorCompletesOnBackPress() {
var signOutCompleted = false
var signOutCancelled = false
var signOutException: Exception? = null
val expectedSignOutUrl = "https://www.arcgis.com/index.html"
val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
composeTestRule.setContent {
IapSignOutAuthenticator(
iapSignOutUrl = expectedSignOutUrl,
onCompleteSignOut = { signOutCompleted = it },
onCancelSignOut = { signOutCancelled = true }
onCancelSignOut = {
signOutCancelled = true
signOutException = it
}
)
}

Expand All @@ -132,5 +186,6 @@ class IapAuthenticatorTest {
// Verify that the sign out was completed
assertThat(signOutCompleted).isTrue()
assertThat(signOutCancelled).isFalse()
assertThat(signOutException).isNull()
}
}
Loading