Skip to content

Commit 6a6c95d

Browse files
authored
fix: Handle configuration changes during WebAuth flow to prevent memory leak (#941)
2 parents 9f10837 + 21a8532 commit 6a6c95d

7 files changed

Lines changed: 704 additions & 10 deletions

File tree

EXAMPLES.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,9 +322,59 @@ WebAuthProvider.logout(account)
322322

323323
})
324324
```
325-
> [!NOTE]
325+
> [!NOTE]
326326
> 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.
327327
328+
## Handling Configuration Changes During Authentication
329+
330+
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:
331+
332+
- **Configuration change**: delivers any cached result on the next `onResume` to the callback
333+
- **Process death**: `AuthenticationActivity` restores OAuth state and processes the redirect. Since static state was wiped, the result is cached and delivered to `loginCallback` on the next `onResume` after `registerCallbacks()` is called
334+
335+
```kotlin
336+
class LoginActivity : AppCompatActivity() {
337+
338+
override fun onCreate(savedInstanceState: Bundle?) {
339+
super.onCreate(savedInstanceState)
340+
WebAuthProvider.registerCallbacks(
341+
lifecycleOwner = this,
342+
loginCallback = object : Callback<Credentials, AuthenticationException> {
343+
override fun onSuccess(result: Credentials) {
344+
// Handle successful login
345+
}
346+
override fun onFailure(error: AuthenticationException) {
347+
// Handle error
348+
}
349+
},
350+
logoutCallback = object : Callback<Void?, AuthenticationException> {
351+
override fun onSuccess(result: Void?) {
352+
// Handle successful logout
353+
}
354+
override fun onFailure(error: AuthenticationException) {
355+
// Handle error
356+
}
357+
}
358+
)
359+
}
360+
361+
fun onLoginClick() {
362+
WebAuthProvider.login(account)
363+
.withScheme("demo")
364+
.start(this, loginCallback)
365+
}
366+
367+
fun onLogoutClick() {
368+
WebAuthProvider.logout(account)
369+
.withScheme("demo")
370+
.start(this, logoutCallback)
371+
}
372+
}
373+
```
374+
375+
> [!NOTE]
376+
> 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.
377+
328378
## Authentication API
329379

330380
The client provides methods to authenticate the user against the Auth0 server.

V4_MIGRATION_GUIDE.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ v4 of the Auth0 Android SDK includes significant build toolchain updates, update
2525
- [**Dependency Changes**](#dependency-changes)
2626
+ [Gson 2.8.9 → 2.11.0](#️-gson-289--2110-transitive-dependency)
2727
+ [DefaultClient.Builder](#defaultclientbuilder)
28+
- [**New APIs**](#new-apis)
29+
+ [Handling Configuration Changes During Authentication](#handling-configuration-changes-during-authentication)
2830

2931
---
3032

@@ -295,6 +297,59 @@ The legacy constructor is deprecated but **not removed** — existing code will
295297
and run. Your IDE will show a deprecation warning with a suggested `ReplaceWith` quick-fix to
296298
migrate to the Builder.
297299

300+
## New APIs
301+
302+
### Handling Configuration Changes During Authentication
303+
304+
v4 fixes a memory leak and lost callback issue when the Activity is destroyed during authentication
305+
(e.g. device rotation, locale change, dark mode toggle). The SDK wraps the callback in a
306+
`LifecycleAwareCallback` that observes the host Activity/Fragment lifecycle. When `onDestroy` fires,
307+
the reference to the callback is immediately nulled out so the destroyed Activity is no longer held
308+
in memory.
309+
310+
If the authentication result arrives while the Activity is being recreated, it is cached internally.
311+
Call `WebAuthProvider.registerCallbacks()` once in your `onCreate()` — this single call handles both
312+
recovery scenarios and manages the callback lifecycle automatically:
313+
314+
```kotlin
315+
class LoginActivity : AppCompatActivity() {
316+
317+
override fun onCreate(savedInstanceState: Bundle?) {
318+
super.onCreate(savedInstanceState)
319+
WebAuthProvider.registerCallbacks(
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+
)
330+
}
331+
332+
fun onLoginClick() {
333+
WebAuthProvider.login(account)
334+
.withScheme("myapp")
335+
.start(this, callback)
336+
}
337+
}
338+
```
339+
340+
`registerCallbacks()` covers both scenarios in one call:
341+
342+
| Scenario | How it's handled |
343+
|----------|-----------------|
344+
| **Configuration change** (rotation, locale, dark mode) | The result is delivered directly to the registered callback once the async token exchange completes. If no callback is registered yet, the result is cached and delivered on the next `onResume` |
345+
| **Process death** (system killed the app while browser was open) | `AuthenticationActivity` restores the OAuth state and processes the redirect. Since all static state (including `callbacks`) was wiped, the result is cached in `pendingLoginResult`. When your Activity is recreated and calls `registerCallbacks()`, the cached result is delivered on the next `onResume` |
346+
347+
> **Note:** Both `loginCallback` and `logoutCallback` are required — this ensures results from either flow are never lost during configuration changes or process death.
348+
349+
> **Note:** If you use the `suspend fun await()` API from a ViewModel coroutine scope, the
350+
> Activity is never captured in the callback chain, so you do not need `registerCallbacks()` calls.
351+
> See the sample app for a ViewModel-based example.
352+
298353
## Getting Help
299354

300355
If you encounter issues during migration:
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.auth0.android.provider
2+
3+
import androidx.lifecycle.DefaultLifecycleObserver
4+
import androidx.lifecycle.LifecycleOwner
5+
import com.auth0.android.authentication.AuthenticationException
6+
import com.auth0.android.callback.Callback
7+
8+
/**
9+
* Wraps a user-provided callback and observes the Activity/Fragment lifecycle.
10+
* When the host is destroyed (e.g. config change), [delegateCallback] is set to null so
11+
* the destroyed Activity is no longer referenced by the SDK.
12+
*
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().
15+
*
16+
* @param S the success type (Credentials for login, Void? for logout)
17+
* @param delegateCallback the user's original callback
18+
* @param lifecycleOwner the Activity or Fragment whose lifecycle to observe
19+
* @param onDetached called when a result arrives but the callback is already detached
20+
*/
21+
internal class LifecycleAwareCallback<S>(
22+
@Volatile private var delegateCallback: Callback<S, AuthenticationException>?,
23+
lifecycleOwner: LifecycleOwner,
24+
private val onDetached: (success: S?, error: AuthenticationException?) -> Unit,
25+
) : Callback<S, AuthenticationException>, DefaultLifecycleObserver {
26+
27+
init {
28+
lifecycleOwner.lifecycle.addObserver(this)
29+
}
30+
31+
override fun onSuccess(result: S) {
32+
val cb = delegateCallback
33+
if (cb != null) {
34+
cb.onSuccess(result)
35+
} else {
36+
onDetached(result, null)
37+
}
38+
}
39+
40+
override fun onFailure(error: AuthenticationException) {
41+
val cb = delegateCallback
42+
if (cb != null) {
43+
cb.onFailure(error)
44+
} else {
45+
onDetached(null, error)
46+
}
47+
}
48+
49+
override fun onDestroy(owner: LifecycleOwner) {
50+
delegateCallback = null
51+
owner.lifecycle.removeObserver(this)
52+
}
53+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ internal class OAuthManager(
2929
@get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
3030
internal val dPoP: DPoP? = null
3131
) : ResumableManager() {
32+
3233
private val parameters: MutableMap<String, String>
3334
private val headers: MutableMap<String, String>
3435
private val ctOptions: CustomTabsOptions

0 commit comments

Comments
 (0)