Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b6cacb3
fix: handle configuration changes during WebAuth flow to prevent memo…
utkrishtsahu Mar 24, 2026
8058fe8
fix: handle configuration changes during WebAuth flow to prevent memo…
utkrishtsahu Mar 31, 2026
8795d30
Merge branch 'v4_development' into SDK-6233-The-SDK-doesn-t-handle-co…
utkrishtsahu Mar 31, 2026
c0831d1
updated Migration doc
utkrishtsahu Mar 31, 2026
61e92fe
fix: handle config changes in WebAuth flow with unified attach() API
utkrishtsahu Apr 2, 2026
6ed2d1f
updated example.md file
utkrishtsahu Apr 2, 2026
6b7675c
refactor: rename attach() to registerCallbacks(), deliver pending res…
utkrishtsahu Apr 2, 2026
ad0df64
make pendingLoginResult and pendingLogoutResult private, use reflecti…
utkrishtsahu Apr 2, 2026
ec02d07
fix: cache result in onRestoreInstanceState for process death recover…
utkrishtsahu Apr 2, 2026
0886953
fix: prevent rotation from canceling auth, clear stale pending result…
utkrishtsahu Apr 2, 2026
c84ed7a
Fixing UT case
utkrishtsahu Apr 2, 2026
196eca1
fix: handle back button cancel and make logoutCallback required in re…
utkrishtsahu Apr 7, 2026
d063324
fix: deliver auth result directly to registered callbacks when token …
utkrishtsahu Apr 8, 2026
ebfed96
fix: broadcast auth result directly to registered callbacks on async …
utkrishtsahu Apr 8, 2026
43d43ca
Handling review comments
utkrishtsahu Apr 9, 2026
5eba7ac
Merge remote-tracking branch 'origin/v4_development' into SDK-6233-Th…
utkrishtsahu Apr 9, 2026
b01245f
fix: address review comments — add @VisibleForTesting annotations, fi…
utkrishtsahu Apr 9, 2026
21a8532
Updtaed UT cases as per review comment
utkrishtsahu Apr 15, 2026
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
51 changes: 50 additions & 1 deletion EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,58 @@ 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. Use `consumePendingLoginResult()` or `consumePendingLogoutResult()` in your `onResume()` to recover it.

```kotlin
class LoginActivity : AppCompatActivity() {

private val loginCallback = object : Callback<Credentials, AuthenticationException> {
override fun onSuccess(result: Credentials) {
// Handle successful login
}
override fun onFailure(error: AuthenticationException) {
// Handle error
}
}

private val logoutCallback = object : Callback<Void?, AuthenticationException> {
override fun onSuccess(result: Void?) {
// Handle successful logout
}
override fun onFailure(error: AuthenticationException) {
// Handle error
}
}

override fun onResume() {
super.onResume()
// Recover any result that arrived while the Activity was being recreated
WebAuthProvider.consumePendingLoginResult(loginCallback)
WebAuthProvider.consumePendingLogoutResult(logoutCallback)
}

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 `consumePending*` calls.

## Authentication API

The client provides methods to authenticate the user against the Auth0 server.
Expand Down
42 changes: 42 additions & 0 deletions V4_MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down Expand Up @@ -295,6 +297,46 @@ 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.
Use `consumePendingLoginResult()` or `consumePendingLogoutResult()` in your `onResume()` to recover it:

```kotlin
class LoginActivity : AppCompatActivity() {
private val callback = object : Callback<Credentials, AuthenticationException> {
override fun onSuccess(result: Credentials) { /* handle credentials */ }
override fun onFailure(error: AuthenticationException) { /* handle error */ }
}

override fun onResume() {
super.onResume()
// Recover result that arrived during configuration change
WebAuthProvider.consumePendingLoginResult(callback)
}

fun onLoginClick() {
WebAuthProvider.login(account)
.withScheme("myapp")
.start(this, callback)
}
}
```

For logout flows, use `WebAuthProvider.consumePendingLogoutResult(callback)` in the same way.

> **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 `consumePending*` calls.
> See the sample app for a ViewModel-based example.

## Getting Help

If you encounter issues during migration:
Expand Down
Original file line number Diff line number Diff line change
@@ -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), [inner] is set to null so
* the destroyed Activity is no longer referenced by the SDK.
*
* If a result arrives after [inner] has been cleared, the [onDetached] lambda
* is invoked to cache the result for later recovery via consumePending*Result().
*
* @param S the success type (Credentials for login, Void? for logout)
* @param inner 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<S>(
@Volatile private var inner: Callback<S, AuthenticationException>?,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • AuthenticationException can be non nullable
  • inner feels too vague. Rename this to something like delegateCallback, wrappedCallback ,targetCallback etc
  • Do we need this to be @volatile ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

lifecycleOwner: LifecycleOwner,
private val onDetached: (success: S?, error: AuthenticationException?) -> Unit,
) : Callback<S, AuthenticationException>, DefaultLifecycleObserver {

init {
lifecycleOwner.lifecycle.addObserver(this)
}

override fun onSuccess(result: S) {
val cb = inner
if (cb != null) {
cb.onSuccess(result)
} else {
onDetached(result, null)
}
}

override fun onFailure(error: AuthenticationException) {
val cb = inner
if (cb != null) {
cb.onFailure(error)
} else {
onDetached(null, error)
}
}

override fun onDestroy(owner: LifecycleOwner) {
inner = null
owner.lifecycle.removeObserver(this)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ internal class OAuthManager(
@get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val dPoP: DPoP? = null
) : ResumableManager() {

private val parameters: MutableMap<String, String>
private val headers: MutableMap<String, String>
private val ctOptions: CustomTabsOptions
Expand Down
108 changes: 106 additions & 2 deletions auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LifecycleOwner
import com.auth0.android.Auth0
import com.auth0.android.annotation.ExperimentalAuth0Api
import com.auth0.android.authentication.AuthenticationException
Expand All @@ -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
Expand All @@ -39,6 +41,62 @@ public object WebAuthProvider {
internal var managerInstance: ResumableManager? = null
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add back the VisibleForTesting annotation here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

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<out S, out E> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be

internal sealed class PendingResult<out S> {
      data class Success<S>(val result: S) : PendingResult<S>()
      data class Failure(val error: AuthenticationException) : PendingResult<Nothing>()
  }

as E will always be AuthenticationException . No need to define a generic parameter for that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

data class Success<S>(val result: S) : PendingResult<S, Nothing>()
data class Failure<E>(val error: E) : PendingResult<Nothing, E>()
}

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val pendingLoginResult =
AtomicReference<PendingResult<Credentials, AuthenticationException>?>(null)
Copy link
Copy Markdown
Contributor

@pmathew92 pmathew92 Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AuthenticationException>? can be non-nullable . Ok with the above suggested change this might not be required at all

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val pendingLogoutResult =
AtomicReference<PendingResult<Void?, AuthenticationException>?>(null)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these two properties needs to be annotated with @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) ? They need to be private properties and needs to be tested via the public methods, in this case consumePendingLoginResult and consumePendingLogoutResult . Refactr the existing tests so that we test them via public methods

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


/**
* Check for and consume a pending login result that arrived during a configuration change.
* Call this in your Activity's `onResume()` to recover results that were delivered while the
* Activity was being recreated (e.g. due to screen rotation).
*
* @param callback the callback to deliver the pending result to
* @return true if a pending result was found and delivered, false otherwise
*/
@JvmStatic
public fun consumePendingLoginResult(callback: Callback<Credentials, AuthenticationException>): Boolean {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename these two methods to something like resumePendingLoginResult resumePendingLogoutResult

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

val result = pendingLoginResult.getAndSet(null) ?: return false
when (result) {
is PendingResult.Success -> callback.onSuccess(result.result)
is PendingResult.Failure -> callback.onFailure(result.error)
}
resetManagerInstance()
return true
}

/**
* Check for and consume a pending logout result that arrived during a configuration change.
* Call this in your Activity's `onResume()` to recover results that were delivered while the
* Activity was being recreated (e.g. due to screen rotation).
*
* @param callback the callback to deliver the pending result to
* @return true if a pending result was found and delivered, false otherwise
*/
@JvmStatic
public fun consumePendingLogoutResult(callback: Callback<Void?, AuthenticationException>): Boolean {
val result = pendingLogoutResult.getAndSet(null) ?: return false
when (result) {
is PendingResult.Success -> callback.onSuccess(result.result)
is PendingResult.Failure -> callback.onFailure(result.error)
}
resetManagerInstance()
return true
}

@JvmStatic
public fun addCallback(callback: Callback<Credentials, AuthenticationException>) {
callbacks += callback
Expand Down Expand Up @@ -242,6 +300,27 @@ public object WebAuthProvider {
* @see AuthenticationException.isAuthenticationCanceled
*/
public fun start(context: Context, callback: Callback<Void?, AuthenticationException>) {
pendingLogoutResult.set(null)

val effectiveCallback = if (context is LifecycleOwner) {
LifecycleAwareCallback<Void?>(
inner = 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)
}

internal fun startInternal(context: Context, callback: Callback<Void?, AuthenticationException>) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be private

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

resetManagerInstance()
if (!ctOptions.hasCompatibleBrowser(context.packageManager)) {
val ex = AuthenticationException(
Expand Down Expand Up @@ -286,7 +365,7 @@ public object WebAuthProvider {
) {
return withContext(coroutineContext) {
suspendCancellableCoroutine { continuation ->
start(context, object : Callback<Void?, AuthenticationException> {
startInternal(context, object : Callback<Void?, AuthenticationException> {
override fun onSuccess(result: Void?) {
continuation.resume(Unit)
}
Expand Down Expand Up @@ -592,6 +671,29 @@ public object WebAuthProvider {
public fun start(
context: Context,
callback: Callback<Credentials, AuthenticationException>
) {
pendingLoginResult.set(null)
val effectiveCallback = if (context is LifecycleOwner) {
LifecycleAwareCallback<Credentials>(
inner = 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)
}

internal fun startInternal(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can also be private

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

context: Context,
callback: Callback<Credentials, AuthenticationException>
) {
resetManagerInstance()
if (!ctOptions.hasCompatibleBrowser(context.packageManager)) {
Expand Down Expand Up @@ -665,7 +767,9 @@ public object WebAuthProvider {
): Credentials {
return withContext(coroutineContext) {
suspendCancellableCoroutine { continuation ->
start(context, object : Callback<Credentials, AuthenticationException> {
// Use startInternal directly — the anonymous callback captures only the
// coroutine continuation, not an Activity, so lifecycle wrapping is not needed
startInternal(context, object : Callback<Credentials, AuthenticationException> {
override fun onSuccess(result: Credentials) {
continuation.resume(result)
}
Expand Down
Loading
Loading