diff --git a/EXAMPLES.md b/EXAMPLES.md index 49c6ac0b6..a2bc7d5d3 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -322,9 +322,59 @@ WebAuthProvider.logout(account) }) ``` -> [!NOTE] +> [!NOTE] > DPoP is supported only on Android version 6.0 (API level 23) and above. Trying to use DPoP in any older versions will result in an exception. +## Handling Configuration Changes During Authentication + +When the Activity is destroyed during authentication due to a configuration change (e.g. device rotation, locale change, dark mode toggle), the SDK caches the authentication result internally. Call `WebAuthProvider.registerCallbacks()` once in your `onCreate()` to recover it. This single call handles both recovery scenarios: + +- **Configuration change**: delivers any cached result on the next `onResume` to the callback +- **Process death**: registers `loginCallback` as a listener and auto-removes it when the Activity is destroyed + +```kotlin +class LoginActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WebAuthProvider.registerCallbacks( + lifecycleOwner = this, + loginCallback = object : Callback { + override fun onSuccess(result: Credentials) { + // Handle successful login + } + override fun onFailure(error: AuthenticationException) { + // Handle error + } + }, + logoutCallback = object : Callback { + override fun onSuccess(result: Void?) { + // Handle successful logout + } + override fun onFailure(error: AuthenticationException) { + // Handle error + } + } + ) + } + + fun onLoginClick() { + WebAuthProvider.login(account) + .withScheme("demo") + .start(this, loginCallback) + } + + fun onLogoutClick() { + WebAuthProvider.logout(account) + .withScheme("demo") + .start(this, logoutCallback) + } +} +``` + +> [!NOTE] +> If you use the `suspend fun await()` API from a ViewModel coroutine scope, the Activity is never captured in the callback chain, so you do not need `registerCallbacks()` calls. + ## Authentication API The client provides methods to authenticate the user against the Auth0 server. diff --git a/V4_MIGRATION_GUIDE.md b/V4_MIGRATION_GUIDE.md index 736a3ddee..e1a71d265 100644 --- a/V4_MIGRATION_GUIDE.md +++ b/V4_MIGRATION_GUIDE.md @@ -25,6 +25,8 @@ v4 of the Auth0 Android SDK includes significant build toolchain updates, update - [**Dependency Changes**](#dependency-changes) + [Gson 2.8.9 → 2.11.0](#️-gson-289--2110-transitive-dependency) + [DefaultClient.Builder](#defaultclientbuilder) +- [**New APIs**](#new-apis) + + [Handling Configuration Changes During Authentication](#handling-configuration-changes-during-authentication) --- @@ -295,6 +297,59 @@ The legacy constructor is deprecated but **not removed** — existing code will and run. Your IDE will show a deprecation warning with a suggested `ReplaceWith` quick-fix to migrate to the Builder. +## New APIs + +### Handling Configuration Changes During Authentication + +v4 fixes a memory leak and lost callback issue when the Activity is destroyed during authentication +(e.g. device rotation, locale change, dark mode toggle). The SDK wraps the callback in a +`LifecycleAwareCallback` that observes the host Activity/Fragment lifecycle. When `onDestroy` fires, +the reference to the callback is immediately nulled out so the destroyed Activity is no longer held +in memory. + +If the authentication result arrives while the Activity is being recreated, it is cached internally. +Call `WebAuthProvider.registerCallbacks()` once in your `onCreate()` — this single call handles both +recovery scenarios and manages the callback lifecycle automatically: + +```kotlin +class LoginActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WebAuthProvider.registerCallbacks( + lifecycleOwner = this, + loginCallback = object : Callback { + override fun onSuccess(result: Credentials) { /* handle credentials */ } + override fun onFailure(error: AuthenticationException) { /* handle error */ } + }, + logoutCallback = object : Callback { + override fun onSuccess(result: Void?) { /* handle logout */ } + override fun onFailure(error: AuthenticationException) { /* handle error */ } + } + ) + } + + fun onLoginClick() { + WebAuthProvider.login(account) + .withScheme("myapp") + .start(this, callback) + } +} +``` + +`registerCallbacks()` covers both scenarios in one call: + +| Scenario | How it's handled | +|----------|-----------------| +| **Configuration change** (rotation, locale, dark mode) | Any result cached while the Activity was recreating is delivered on the next `onResume` | +| **Process death** (system killed the app while browser was open) | `loginCallback` is registered as a listener and auto-removed when `lifecycleOwner` is destroyed — no manual `addCallback`/`removeCallback` calls needed | + +> **Note:** `logoutCallback` is optional — pass it only if your screen initiates logout flows. + +> **Note:** If you use the `suspend fun await()` API from a ViewModel coroutine scope, the +> Activity is never captured in the callback chain, so you do not need `registerCallbacks()` calls. +> See the sample app for a ViewModel-based example. + ## Getting Help If you encounter issues during migration: diff --git a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt index b0f413cc6..b2cf97b54 100644 --- a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt +++ b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.kt @@ -57,12 +57,14 @@ public open class AuthenticationActivity : Activity() { launchAuthenticationIntent() return } + // Only deliver result if intent.data is present (user returned from browser). + // If data is null, the Activity resumed due to rotation or other config change + // while the CustomTab is still open — don't finish, let the browser continue. val resultMissing = authenticationIntent.data == null - if (resultMissing) { - setResult(RESULT_CANCELED) + if (!resultMissing) { + deliverAuthenticationResult(authenticationIntent) + finish() } - deliverAuthenticationResult(authenticationIntent) - finish() } override fun onDestroy() { diff --git a/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt b/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt new file mode 100644 index 000000000..e1081e491 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt @@ -0,0 +1,53 @@ +package com.auth0.android.provider + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.callback.Callback + +/** + * Wraps a user-provided callback and observes the Activity/Fragment lifecycle. + * When the host is destroyed (e.g. config change), [delegateCallback] is set to null so + * the destroyed Activity is no longer referenced by the SDK. + * + * If a result arrives after [delegateCallback] has been cleared, the [onDetached] lambda + * is invoked to cache the result for later recovery via resumePending*Result(). + * + * @param S the success type (Credentials for login, Void? for logout) + * @param delegateCallback the user's original callback + * @param lifecycleOwner the Activity or Fragment whose lifecycle to observe + * @param onDetached called when a result arrives but the callback is already detached + */ +internal class LifecycleAwareCallback( + @Volatile private var delegateCallback: Callback?, + lifecycleOwner: LifecycleOwner, + private val onDetached: (success: S?, error: AuthenticationException?) -> Unit, +) : Callback, DefaultLifecycleObserver { + + init { + lifecycleOwner.lifecycle.addObserver(this) + } + + override fun onSuccess(result: S) { + val cb = delegateCallback + if (cb != null) { + cb.onSuccess(result) + } else { + onDetached(result, null) + } + } + + override fun onFailure(error: AuthenticationException) { + val cb = delegateCallback + if (cb != null) { + cb.onFailure(error) + } else { + onDetached(null, error) + } + } + + override fun onDestroy(owner: LifecycleOwner) { + delegateCallback = null + owner.lifecycle.removeObserver(this) + } +} diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt index b09366e61..718d3a788 100644 --- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt +++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.kt @@ -29,6 +29,7 @@ internal class OAuthManager( @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val dPoP: DPoP? = null ) : ResumableManager() { + private val parameters: MutableMap private val headers: MutableMap private val ctOptions: CustomTabsOptions diff --git a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt index 70cf647f3..3a174fbaa 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -5,7 +5,8 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.util.Log -import androidx.annotation.VisibleForTesting +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import com.auth0.android.Auth0 import com.auth0.android.annotation.ExperimentalAuth0Api import com.auth0.android.authentication.AuthenticationException @@ -18,6 +19,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.util.Locale import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.CoroutineContext import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -32,18 +34,98 @@ public object WebAuthProvider { private val TAG: String? = WebAuthProvider::class.simpleName private const val KEY_BUNDLE_OAUTH_MANAGER_STATE = "oauth_manager_state" - private val callbacks = CopyOnWriteArraySet>() + internal val callbacks = CopyOnWriteArraySet>() @JvmStatic - @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal var managerInstance: ResumableManager? = null private set + /** + * Represents a pending authentication or logout result that arrived while + * the original callback was no longer reachable (e.g. Activity destroyed + * during a configuration change). + */ + internal sealed class PendingResult { + data class Success(val result: S) : PendingResult() + data class Failure(val error: AuthenticationException) : PendingResult() + } + + internal val pendingLoginResult = AtomicReference?>(null) + + internal val pendingLogoutResult = AtomicReference?>(null) + + /** + * Registers login (and optionally logout) callbacks for the duration of the given + * [lifecycleOwner]'s lifetime. Call this once in `onCreate()` — it covers both recovery + * scenarios automatically: + * + * - **Process death**: [loginCallback] is registered immediately so that if the process + * was killed while the browser was open, the result is delivered when the Activity is + * restored. The callback is automatically unregistered when [lifecycleOwner] is destroyed, + * so there is no need to call [removeCallback] manually. + * - **Configuration change** (rotation, locale, dark mode): any login or logout result + * that arrived while the Activity was being recreated is delivered on the next `onResume`. + * + * ```kotlin + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * WebAuthProvider.registerCallbacks(this, loginCallback = callback, logoutCallback = voidCallback) + * } + * ``` + * + * @param lifecycleOwner the Activity or Fragment whose lifecycle to observe + * @param loginCallback receives login results (both direct delivery and recovered results) + * @param logoutCallback receives logout results recovered after a configuration change + */ + @JvmStatic + public fun registerCallbacks( + lifecycleOwner: LifecycleOwner, + loginCallback: Callback, + logoutCallback: Callback? = null, + ) { + // Process-death recovery: register immediately so result is routed here on restore + callbacks += loginCallback + lifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + // Config-change recovery: deliver any result cached while Activity was recreating + pendingLoginResult.getAndSet(null)?.let { pending -> + when (pending) { + is PendingResult.Success -> loginCallback.onSuccess(pending.result) + is PendingResult.Failure -> loginCallback.onFailure(pending.error) + } + resetManagerInstance() + } + logoutCallback?.let { cb -> + pendingLogoutResult.getAndSet(null)?.let { pending -> + when (pending) { + is PendingResult.Success -> cb.onSuccess(pending.result) + is PendingResult.Failure -> cb.onFailure(pending.error) + } + resetManagerInstance() + } + } + } + + override fun onDestroy(owner: LifecycleOwner) { + callbacks -= loginCallback + owner.lifecycle.removeObserver(this) + } + }) + } + + @Deprecated( + message = "Use registerCallbacks() instead — it registers the callback and auto-removes it when the lifecycle owner is destroyed.", + replaceWith = ReplaceWith("registerCallbacks(lifecycleOwner, loginCallback = callback)") + ) @JvmStatic public fun addCallback(callback: Callback) { callbacks += callback } + @Deprecated( + message = "Use registerCallbacks() instead — it auto-removes the callback when the lifecycle owner is destroyed.", + replaceWith = ReplaceWith("registerCallbacks(lifecycleOwner, loginCallback = callback)") + ) @JvmStatic public fun removeCallback(callback: Callback) { callbacks -= callback @@ -122,14 +204,22 @@ public object WebAuthProvider { state, object : Callback { override fun onSuccess(result: Credentials) { - for (callback in callbacks) { - callback.onSuccess(result) + if (callbacks.isNotEmpty()) { + for (callback in callbacks) { + callback.onSuccess(result) + } + } else { + pendingLoginResult.set(PendingResult.Success(result)) } } override fun onFailure(error: AuthenticationException) { - for (callback in callbacks) { - callback.onFailure(error) + if (callbacks.isNotEmpty()) { + for (callback in callbacks) { + callback.onFailure(error) + } + } else { + pendingLoginResult.set(PendingResult.Failure(error)) } } }, @@ -140,7 +230,6 @@ public object WebAuthProvider { } @JvmStatic - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal fun resetManagerInstance() { managerInstance = null } @@ -242,6 +331,28 @@ public object WebAuthProvider { * @see AuthenticationException.isAuthenticationCanceled */ public fun start(context: Context, callback: Callback) { + pendingLogoutResult.set(null) + pendingLoginResult.set(null) + + val effectiveCallback = if (context is LifecycleOwner) { + LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = context as LifecycleOwner, + onDetached = { _: Void?, error: AuthenticationException? -> + if (error != null) { + pendingLogoutResult.set(PendingResult.Failure(error)) + } else { + pendingLogoutResult.set(PendingResult.Success(null)) + } + } + ) + } else { + callback + } + startInternal(context, effectiveCallback) + } + + private fun startInternal(context: Context, callback: Callback) { resetManagerInstance() if (!ctOptions.hasCompatibleBrowser(context.packageManager)) { val ex = AuthenticationException( @@ -286,7 +397,7 @@ public object WebAuthProvider { ) { return withContext(coroutineContext) { suspendCancellableCoroutine { continuation -> - start(context, object : Callback { + startInternal(context, object : Callback { override fun onSuccess(result: Void?) { continuation.resume(Unit) } @@ -559,7 +670,6 @@ public object WebAuthProvider { return this } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal fun withPKCE(pkce: PKCE): Builder { this.pkce = pkce return this @@ -592,6 +702,30 @@ public object WebAuthProvider { public fun start( context: Context, callback: Callback + ) { + pendingLoginResult.set(null) + pendingLogoutResult.set(null) + val effectiveCallback = if (context is LifecycleOwner) { + LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = context as LifecycleOwner, + onDetached = { success: Credentials?, error: AuthenticationException? -> + if (success != null) { + pendingLoginResult.set(PendingResult.Success(success)) + } else if (error != null) { + pendingLoginResult.set(PendingResult.Failure(error)) + } + } + ) + } else { + callback + } + startInternal(context, effectiveCallback) + } + + private fun startInternal( + context: Context, + callback: Callback ) { resetManagerInstance() if (!ctOptions.hasCompatibleBrowser(context.packageManager)) { @@ -665,7 +799,9 @@ public object WebAuthProvider { ): Credentials { return withContext(coroutineContext) { suspendCancellableCoroutine { continuation -> - start(context, object : Callback { + // Use startInternal directly — the anonymous callback captures only the + // coroutine continuation, not an Activity, so lifecycle wrapping is not needed + startInternal(context, object : Callback { override fun onSuccess(result: Credentials) { continuation.resume(result) } diff --git a/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.kt b/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.kt index ace70dd8f..8e86aebbe 100644 --- a/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.kt @@ -214,14 +214,10 @@ public class AuthenticationActivityTest { MatcherAssert.assertThat(launchAsTwaCaptor.value, Is.`is`(false)) MatcherAssert.assertThat(activity.deliveredIntent, Is.`is`(Matchers.nullValue())) activityController.pause().stop() - //Browser is shown + //Browser is shown, resume WITHOUT new intent — should NOT deliver or finish activityController.start().resume() - MatcherAssert.assertThat(activity.deliveredIntent, Is.`is`(Matchers.notNullValue())) - MatcherAssert.assertThat( - activity.deliveredIntent!!.data, - Is.`is`(Matchers.nullValue()) - ) //null data == canceled - MatcherAssert.assertThat(activity.isFinishing, Is.`is`(true)) + MatcherAssert.assertThat(activity.deliveredIntent, Is.`is`(Matchers.nullValue())) //nothing delivered + MatcherAssert.assertThat(activity.isFinishing, Is.`is`(false)) //still waiting for result activityController.destroy() Mockito.verify(customTabsController).unbindService() } diff --git a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt index e8bebf333..320330ca7 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.kt @@ -2,6 +2,9 @@ package com.auth0.android.provider import android.app.Activity import android.content.Context +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import android.content.Intent import android.net.Uri import android.os.Parcelable @@ -113,6 +116,10 @@ public class WebAuthProviderTest { ) `when`(mockKeyStore.hasKeyPair()).thenReturn(false) + + // Clear any pending results left over from previous tests + setPendingLoginResult(null) + setPendingLogoutResult(null) } @@ -3064,6 +3071,369 @@ public class WebAuthProviderTest { verify(options, Mockito.never()).copyWithEphemeralBrowsing() } + @Test + public fun shouldCacheLoginResultWhenLifecycleCallbackIsDetachedOnDestroy() { + val credentials = Mockito.mock(Credentials::class.java) + var capturedSuccess: Credentials? = null + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { success, error -> + capturedSuccess = success + capturedError = error + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onSuccess(credentials) + + Assert.assertEquals(credentials, capturedSuccess) + Assert.assertNull(capturedError) + verify(callback, Mockito.never()).onSuccess(any()) // old callback never called + } + + @Test + public fun shouldCacheLoginFailureWhenLifecycleCallbackIsDetachedOnDestroy() { + val error = AuthenticationException("canceled", "User canceled") + var capturedSuccess: Credentials? = null + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { success, detachedError -> + capturedSuccess = success + capturedError = detachedError + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onFailure(error) + + Assert.assertNull(capturedSuccess) + Assert.assertEquals(error, capturedError) + verify(callback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldDeliverDirectlyWhenLifecycleCallbackIsAlive() { + val credentials = Mockito.mock(Credentials::class.java) + var onDetachedCalled = false + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> onDetachedCalled = true } + ) + + lifecycleCallback.onSuccess(credentials) + + verify(callback).onSuccess(credentials) + Assert.assertFalse(onDetachedCalled) + } + + @Test + public fun shouldDeliverFailureDirectlyWhenLifecycleCallbackIsAlive() { + val error = AuthenticationException("canceled", "User canceled") + var onDetachedCalled = false + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> onDetachedCalled = true } + ) + + lifecycleCallback.onFailure(error) + + verify(callback).onFailure(error) + Assert.assertFalse(onDetachedCalled) + } + + @Test + public fun shouldCacheLogoutSuccessWhenLifecycleCallbackIsDetachedOnDestroy() { + var capturedSuccess = false + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = voidCallback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, error -> + capturedError = error + if (error == null) capturedSuccess = true + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onSuccess(null) + + Assert.assertTrue(capturedSuccess) + Assert.assertNull(capturedError) + verify(voidCallback, Mockito.never()).onSuccess(any()) + } + + @Test + public fun shouldCacheLogoutFailureWhenLifecycleCallbackIsDetachedOnDestroy() { + val error = AuthenticationException("canceled", "User closed the browser") + var capturedSuccess = false + var capturedError: AuthenticationException? = null + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = voidCallback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, detachedError -> + capturedError = detachedError + if (detachedError == null) capturedSuccess = true + } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + lifecycleCallback.onFailure(error) + + Assert.assertFalse(capturedSuccess) + Assert.assertEquals(error, capturedError) + verify(voidCallback, Mockito.never()).onFailure(any()) + } + + @Test + public fun shouldDeliverLogoutDirectlyWhenLifecycleCallbackIsAlive() { + var onDetachedCalled = false + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = voidCallback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> onDetachedCalled = true } + ) + + lifecycleCallback.onSuccess(null) + + verify(voidCallback).onSuccess(null) + Assert.assertFalse(onDetachedCalled) + } + + @Test + public fun shouldRegisterAsLifecycleObserverOnInit() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> } + ) + + verify(lifecycle).addObserver(lifecycleCallback) + } + + @Test + public fun shouldUnregisterLifecycleObserverOnDestroy() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val lifecycleCallback = LifecycleAwareCallback( + delegateCallback = callback, + lifecycleOwner = lifecycleOwner, + onDetached = { _, _ -> } + ) + + lifecycleCallback.onDestroy(lifecycleOwner) + + verify(lifecycle).removeObserver(lifecycleCallback) + } + + @Test + public fun shouldClearPendingLoginResultOnNewLoginStart() { + val staleCredentials = Mockito.mock(Credentials::class.java) + setPendingLoginResult(WebAuthProvider.PendingResult.Success(staleCredentials)) + + login(account).start(activity, callback) + + Assert.assertNull(getPendingLoginResult()) + } + + @Test + public fun shouldClearPendingLogoutResultOnNewLogoutStart() { + setPendingLogoutResult(WebAuthProvider.PendingResult.Success(null)) + + logout(account).start(activity, voidCallback) + + Assert.assertNull(getPendingLogoutResult()) + } + + + @Test + public fun shouldDeliverPendingLoginResultOnResume() { + val credentials = Mockito.mock(Credentials::class.java) + setPendingLoginResult(WebAuthProvider.PendingResult.Success(credentials)) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + // Pending result is delivered on onResume, not immediately + verify(callback, Mockito.never()).onSuccess(any()) + observerCaptor.firstValue.onResume(lifecycleOwner) + + verify(callback).onSuccess(credentials) + Assert.assertNull(getPendingLoginResult()) + } + + @Test + public fun shouldDeliverPendingLoginFailureOnResume() { + val error = AuthenticationException("canceled", "User canceled") + setPendingLoginResult(WebAuthProvider.PendingResult.Failure(error)) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) + + verify(callback).onFailure(error) + Assert.assertNull(getPendingLoginResult()) + } + + @Test + public fun shouldDeliverPendingLogoutResultOnResume() { + setPendingLogoutResult(WebAuthProvider.PendingResult.Success(null)) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) + + verify(voidCallback).onSuccess(null) + Assert.assertNull(getPendingLogoutResult()) + } + + @Test + public fun shouldDeliverPendingLogoutFailureOnResume() { + val error = AuthenticationException("canceled", "User closed the browser") + setPendingLogoutResult(WebAuthProvider.PendingResult.Failure(error)) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback, logoutCallback = voidCallback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + observerCaptor.firstValue.onResume(lifecycleOwner) + + verify(voidCallback).onFailure(error) + Assert.assertNull(getPendingLogoutResult()) + } + + @Test + public fun shouldRegisterLoginCallbackForProcessDeathOnRegisterCallbacks() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback) + + Assert.assertTrue(WebAuthProvider.callbacks.contains(callback)) + } + + @Test + public fun shouldAutoRemoveLoginCallbackOnDestroyAfterRegisterCallbacks() { + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + Assert.assertTrue(WebAuthProvider.callbacks.contains(callback)) + + // Simulate onDestroy + observerCaptor.firstValue.onDestroy(lifecycleOwner) + + Assert.assertFalse(WebAuthProvider.callbacks.contains(callback)) + } + + @Test + public fun shouldNotDeliverLogoutResultWhenNoLogoutCallbackPassedToRegisterCallbacks() { + setPendingLogoutResult(WebAuthProvider.PendingResult.Success(null)) + + val lifecycleOwner = Mockito.mock(LifecycleOwner::class.java) + val lifecycle = Mockito.mock(Lifecycle::class.java) + Mockito.`when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + + val observerCaptor = argumentCaptor() + WebAuthProvider.registerCallbacks(lifecycleOwner, loginCallback = callback) + verify(lifecycle).addObserver(observerCaptor.capture()) + + // registerCallbacks without logoutCallback — pending logout result should stay untouched + observerCaptor.firstValue.onResume(lifecycleOwner) + + verify(voidCallback, Mockito.never()).onSuccess(any()) + Assert.assertNotNull(getPendingLogoutResult()) + } + + // Direct access — pendingLoginResult/pendingLogoutResult are internal in WebAuthProvider + private fun setPendingLoginResult(result: WebAuthProvider.PendingResult?) { + WebAuthProvider.pendingLoginResult.set(result) + } + + private fun getPendingLoginResult(): WebAuthProvider.PendingResult? { + return WebAuthProvider.pendingLoginResult.get() + } + + private fun setPendingLogoutResult(result: WebAuthProvider.PendingResult?) { + WebAuthProvider.pendingLogoutResult.set(result) + } + + private fun getPendingLogoutResult(): WebAuthProvider.PendingResult? { + return WebAuthProvider.pendingLogoutResult.get() + } + private companion object { private const val KEY_STATE = "state" private const val KEY_NONCE = "nonce"