Skip to content

Commit 61e92fe

Browse files
committed
fix: handle config changes in WebAuth flow with unified attach() API
1 parent c0831d1 commit 61e92fe

File tree

4 files changed

+214
-155
lines changed

4 files changed

+214
-155
lines changed

V4_MIGRATION_GUIDE.md

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -308,19 +308,25 @@ the reference to the callback is immediately nulled out so the destroyed Activit
308308
in memory.
309309

310310
If the authentication result arrives while the Activity is being recreated, it is cached internally.
311-
Use `consumePendingLoginResult()` or `consumePendingLogoutResult()` in your `onResume()` to recover it:
311+
Use `WebAuthProvider.attach()` in your `onResume()` to recover it — this single call handles both
312+
recovery scenarios and manages the callback lifecycle automatically:
312313

313314
```kotlin
314315
class LoginActivity : AppCompatActivity() {
315-
private val callback = object : Callback<Credentials, AuthenticationException> {
316-
override fun onSuccess(result: Credentials) { /* handle credentials */ }
317-
override fun onFailure(error: AuthenticationException) { /* handle error */ }
318-
}
319316

320317
override fun onResume() {
321318
super.onResume()
322-
// Recover result that arrived during configuration change
323-
WebAuthProvider.consumePendingLoginResult(callback)
319+
WebAuthProvider.attach(
320+
lifecycleOwner = this,
321+
loginCallback = object : Callback<Credentials, AuthenticationException> {
322+
override fun onSuccess(result: Credentials) { /* handle credentials */ }
323+
override fun onFailure(error: AuthenticationException) { /* handle error */ }
324+
},
325+
logoutCallback = object : Callback<Void?, AuthenticationException> {
326+
override fun onSuccess(result: Void?) { /* handle logout */ }
327+
override fun onFailure(error: AuthenticationException) { /* handle error */ }
328+
}
329+
)
324330
}
325331

326332
fun onLoginClick() {
@@ -331,10 +337,17 @@ class LoginActivity : AppCompatActivity() {
331337
}
332338
```
333339

334-
For logout flows, use `WebAuthProvider.consumePendingLogoutResult(callback)` in the same way.
340+
`attach()` covers both scenarios in one call:
341+
342+
| Scenario | How it's handled |
343+
|----------|-----------------|
344+
| **Configuration change** (rotation, locale, dark mode) | Any result cached while the Activity was recreating is delivered immediately to the callback |
345+
| **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 |
346+
347+
> **Note:** `logoutCallback` is optional — pass it only if your screen initiates logout flows.
335348
336349
> **Note:** If you use the `suspend fun await()` API from a ViewModel coroutine scope, the
337-
> Activity is never captured in the callback chain, so you do not need `consumePending*` calls.
350+
> Activity is never captured in the callback chain, so you do not need `attach()` calls.
338351
> See the sample app for a ViewModel-based example.
339352
340353
## Getting Help

auth0/src/main/java/com/auth0/android/provider/LifecycleAwareCallback.kt

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,19 @@ import com.auth0.android.callback.Callback
77

88
/**
99
* Wraps a user-provided callback and observes the Activity/Fragment lifecycle.
10-
* When the host is destroyed (e.g. config change), [inner] is set to null so
10+
* When the host is destroyed (e.g. config change), [delegateCallback] is set to null so
1111
* the destroyed Activity is no longer referenced by the SDK.
1212
*
13-
* If a result arrives after [inner] has been cleared, the [onDetached] lambda
14-
* is invoked to cache the result for later recovery via consumePending*Result().
13+
* If a result arrives after [delegateCallback] has been cleared, the [onDetached] lambda
14+
* is invoked to cache the result for later recovery via resumePending*Result().
1515
*
1616
* @param S the success type (Credentials for login, Void? for logout)
17-
* @param inner the user's original callback
17+
* @param delegateCallback the user's original callback
1818
* @param lifecycleOwner the Activity or Fragment whose lifecycle to observe
1919
* @param onDetached called when a result arrives but the callback is already detached
2020
*/
2121
internal class LifecycleAwareCallback<S>(
22-
@Volatile private var inner: Callback<S, AuthenticationException>?,
22+
private var delegateCallback: Callback<S, AuthenticationException>?,
2323
lifecycleOwner: LifecycleOwner,
2424
private val onDetached: (success: S?, error: AuthenticationException?) -> Unit,
2525
) : Callback<S, AuthenticationException>, DefaultLifecycleObserver {
@@ -29,7 +29,7 @@ internal class LifecycleAwareCallback<S>(
2929
}
3030

3131
override fun onSuccess(result: S) {
32-
val cb = inner
32+
val cb = delegateCallback
3333
if (cb != null) {
3434
cb.onSuccess(result)
3535
} else {
@@ -38,7 +38,7 @@ internal class LifecycleAwareCallback<S>(
3838
}
3939

4040
override fun onFailure(error: AuthenticationException) {
41-
val cb = inner
41+
val cb = delegateCallback
4242
if (cb != null) {
4343
cb.onFailure(error)
4444
} else {
@@ -47,7 +47,7 @@ internal class LifecycleAwareCallback<S>(
4747
}
4848

4949
override fun onDestroy(owner: LifecycleOwner) {
50-
inner = null
50+
delegateCallback = null
5151
owner.lifecycle.removeObserver(this)
5252
}
5353
}

auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt

Lines changed: 68 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import android.content.Intent
55
import android.net.Uri
66
import android.os.Bundle
77
import android.util.Log
8-
import androidx.annotation.VisibleForTesting
8+
import androidx.lifecycle.DefaultLifecycleObserver
99
import androidx.lifecycle.LifecycleOwner
1010
import com.auth0.android.Auth0
1111
import com.auth0.android.annotation.ExperimentalAuth0Api
@@ -34,10 +34,9 @@ public object WebAuthProvider {
3434
private val TAG: String? = WebAuthProvider::class.simpleName
3535
private const val KEY_BUNDLE_OAUTH_MANAGER_STATE = "oauth_manager_state"
3636

37-
private val callbacks = CopyOnWriteArraySet<Callback<Credentials, AuthenticationException>>()
37+
internal val callbacks = CopyOnWriteArraySet<Callback<Credentials, AuthenticationException>>()
3838

3939
@JvmStatic
40-
@get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
4140
internal var managerInstance: ResumableManager? = null
4241
private set
4342

@@ -46,62 +45,86 @@ public object WebAuthProvider {
4645
* the original callback was no longer reachable (e.g. Activity destroyed
4746
* during a configuration change).
4847
*/
49-
internal sealed class PendingResult<out S, out E> {
50-
data class Success<S>(val result: S) : PendingResult<S, Nothing>()
51-
data class Failure<E>(val error: E) : PendingResult<Nothing, E>()
48+
internal sealed class PendingResult<out S> {
49+
data class Success<S>(val result: S) : PendingResult<S>()
50+
data class Failure(val error: AuthenticationException) : PendingResult<Nothing>()
5251
}
5352

54-
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
55-
internal val pendingLoginResult =
56-
AtomicReference<PendingResult<Credentials, AuthenticationException>?>(null)
53+
internal val pendingLoginResult = AtomicReference<PendingResult<Credentials>?>(null)
5754

58-
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
59-
internal val pendingLogoutResult =
60-
AtomicReference<PendingResult<Void?, AuthenticationException>?>(null)
55+
internal val pendingLogoutResult = AtomicReference<PendingResult<Void?>?>(null)
6156

6257
/**
63-
* Check for and consume a pending login result that arrived during a configuration change.
64-
* Call this in your Activity's `onResume()` to recover results that were delivered while the
65-
* Activity was being recreated (e.g. due to screen rotation).
58+
* Attaches login (and optionally logout) callbacks for the duration of the given
59+
* [lifecycleOwner]'s lifetime. Call this once in `onResume()` — it covers both recovery
60+
* scenarios automatically:
6661
*
67-
* @param callback the callback to deliver the pending result to
68-
* @return true if a pending result was found and delivered, false otherwise
69-
*/
70-
@JvmStatic
71-
public fun consumePendingLoginResult(callback: Callback<Credentials, AuthenticationException>): Boolean {
72-
val result = pendingLoginResult.getAndSet(null) ?: return false
73-
when (result) {
74-
is PendingResult.Success -> callback.onSuccess(result.result)
75-
is PendingResult.Failure -> callback.onFailure(result.error)
76-
}
77-
resetManagerInstance()
78-
return true
79-
}
80-
81-
/**
82-
* Check for and consume a pending logout result that arrived during a configuration change.
83-
* Call this in your Activity's `onResume()` to recover results that were delivered while the
84-
* Activity was being recreated (e.g. due to screen rotation).
62+
* - **Configuration change** (rotation, locale, dark mode): if a login or logout result
63+
* arrived while the Activity was being recreated, it is delivered immediately.
64+
* - **Process death**: [loginCallback] is registered as a listener so that if the process
65+
* was killed while the browser was open, the result is delivered when the Activity is
66+
* restored. The callback is automatically unregistered when [lifecycleOwner] is destroyed,
67+
* so there is no need to call [removeCallback] manually.
68+
*
69+
* ```kotlin
70+
* override fun onResume() {
71+
* super.onResume()
72+
* WebAuthProvider.attach(this, loginCallback = callback, logoutCallback = voidCallback)
73+
* }
74+
* ```
8575
*
86-
* @param callback the callback to deliver the pending result to
87-
* @return true if a pending result was found and delivered, false otherwise
76+
* @param lifecycleOwner the Activity or Fragment whose lifecycle to observe
77+
* @param loginCallback receives login results (both direct delivery and recovered results)
78+
* @param logoutCallback receives logout results recovered after a configuration change
8879
*/
8980
@JvmStatic
90-
public fun consumePendingLogoutResult(callback: Callback<Void?, AuthenticationException>): Boolean {
91-
val result = pendingLogoutResult.getAndSet(null) ?: return false
92-
when (result) {
93-
is PendingResult.Success -> callback.onSuccess(result.result)
94-
is PendingResult.Failure -> callback.onFailure(result.error)
95-
}
96-
resetManagerInstance()
97-
return true
81+
public fun attach(
82+
lifecycleOwner: LifecycleOwner,
83+
loginCallback: Callback<Credentials, AuthenticationException>,
84+
logoutCallback: Callback<Void?, AuthenticationException>? = null,
85+
) {
86+
// Process-death recovery: register and auto-remove on destroy
87+
callbacks += loginCallback
88+
lifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
89+
override fun onDestroy(owner: LifecycleOwner) {
90+
callbacks -= loginCallback
91+
owner.lifecycle.removeObserver(this)
92+
}
93+
})
94+
95+
// Config-change recovery: deliver any result cached while Activity was recreating
96+
pendingLoginResult.getAndSet(null)?.let { pending ->
97+
when (pending) {
98+
is PendingResult.Success -> loginCallback.onSuccess(pending.result)
99+
is PendingResult.Failure -> loginCallback.onFailure(pending.error)
100+
}
101+
resetManagerInstance()
102+
}
103+
104+
logoutCallback?.let { cb ->
105+
pendingLogoutResult.getAndSet(null)?.let { pending ->
106+
when (pending) {
107+
is PendingResult.Success -> cb.onSuccess(pending.result)
108+
is PendingResult.Failure -> cb.onFailure(pending.error)
109+
}
110+
resetManagerInstance()
111+
}
112+
}
98113
}
99114

115+
@Deprecated(
116+
message = "Use attach() instead — it registers the callback and auto-removes it when the lifecycle owner is destroyed.",
117+
replaceWith = ReplaceWith("attach(lifecycleOwner, loginCallback = callback)")
118+
)
100119
@JvmStatic
101120
public fun addCallback(callback: Callback<Credentials, AuthenticationException>) {
102121
callbacks += callback
103122
}
104123

124+
@Deprecated(
125+
message = "Use attach() instead — it auto-removes the callback when the lifecycle owner is destroyed.",
126+
replaceWith = ReplaceWith("attach(lifecycleOwner, loginCallback = callback)")
127+
)
105128
@JvmStatic
106129
public fun removeCallback(callback: Callback<Credentials, AuthenticationException>) {
107130
callbacks -= callback
@@ -198,7 +221,6 @@ public object WebAuthProvider {
198221
}
199222

200223
@JvmStatic
201-
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
202224
internal fun resetManagerInstance() {
203225
managerInstance = null
204226
}
@@ -304,7 +326,7 @@ public object WebAuthProvider {
304326

305327
val effectiveCallback = if (context is LifecycleOwner) {
306328
LifecycleAwareCallback<Void?>(
307-
inner = callback,
329+
delegateCallback = callback,
308330
lifecycleOwner = context as LifecycleOwner,
309331
onDetached = { _: Void?, error: AuthenticationException? ->
310332
if (error != null) {
@@ -638,7 +660,6 @@ public object WebAuthProvider {
638660
return this
639661
}
640662

641-
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
642663
internal fun withPKCE(pkce: PKCE): Builder {
643664
this.pkce = pkce
644665
return this
@@ -675,7 +696,7 @@ public object WebAuthProvider {
675696
pendingLoginResult.set(null)
676697
val effectiveCallback = if (context is LifecycleOwner) {
677698
LifecycleAwareCallback<Credentials>(
678-
inner = callback,
699+
delegateCallback = callback,
679700
lifecycleOwner = context as LifecycleOwner,
680701
onDetached = { success: Credentials?, error: AuthenticationException? ->
681702
if (success != null) {

0 commit comments

Comments
 (0)