Skip to content

fix: Handle configuration changes during WebAuth flow to prevent memory leak#941

Open
utkrishtsahu wants to merge 11 commits intov4_developmentfrom
SDK-6233-The-SDK-doesn-t-handle-configuration-changes-correctly-on-device-rotation-and-causes-a-memory-leak
Open

fix: Handle configuration changes during WebAuth flow to prevent memory leak#941
utkrishtsahu wants to merge 11 commits intov4_developmentfrom
SDK-6233-The-SDK-doesn-t-handle-configuration-changes-correctly-on-device-rotation-and-causes-a-memory-leak

Conversation

@utkrishtsahu
Copy link
Copy Markdown
Contributor

@utkrishtsahu utkrishtsahu commented Mar 24, 2026

Changes

When the Activity is destroyed during a WebAuth login or logout flow due to a configuration change (e.g. device rotation, locale change, dark mode toggle), the SDK previously retained a strong reference to the destroyed Activity via the static WebAuthProvider.managerInstance → OAuthManager/LogoutManager → callback chain, causing a memory leak. Additionally, the authentication result was delivered to the destroyed Activity's callback, which could crash or silently fail.

Root cause: WebAuthProvider.managerInstance is a static field. OAuthManager and LogoutManager held a strong reference to the callback, which captured the Activity/Fragment this. On configuration change, the old Activity was destroyed but never garbage collected because of this reference chain.

What changed:

  • LifecycleAwareCallback (new): A DefaultLifecycleObserver wrapper around the user's callback. It observes the host Activity/Fragment lifecycle and sets delegateCallback = null immediately and deterministically when onDestroy fires — breaking the memory leak chain without relying on GC timing. If a result arrives after onDestroy, it is routed to the onDetached lambda instead of the dead callback.

  • WebAuthProvider: login().start() and logout().start() now wrap the callback in LifecycleAwareCallback when the context is a LifecycleOwner. Added the following new public API:

    • registerCallbacks(lifecycleOwner, loginCallback, logoutCallback?) — Single call in onCreate() that handles both recovery scenarios: registers loginCallback for process-death recovery immediately, delivers any result cached during a config change on the next onResume via an internal lifecycle observer, and auto-removes the callback when the lifecycle owner is destroyed. Deprecated addCallback/removeCallback in favour of registerCallbacks().
    • PendingResult (internal sealed class) — Represents a cached Success or Failure(AuthenticationException) result, stored in an AtomicReference for thread safety.
  • LogoutBuilder.startInternal changed from internal to private.

  • V4_MIGRATION_GUIDE.md: Updated with accurate description and usage example for registerCallbacks().

Usage:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WebAuthProvider.registerCallbacks(this, loginCallback = callback, logoutCallback = voidCallback)
}

The suspend fun await() API is unaffected since it does not go through LifecycleAwareCallback.

Testing

Unit tests added (19 new tests in WebAuthProviderTest.kt):

  • shouldCacheLoginResultWhenLifecycleCallbackIsDetachedOnDestroy
  • shouldCacheLoginFailureWhenLifecycleCallbackIsDetachedOnDestroy
  • shouldCacheLogoutSuccessWhenLifecycleCallbackIsDetachedOnDestroy
  • shouldCacheLogoutFailureWhenLifecycleCallbackIsDetachedOnDestroy
  • shouldDeliverDirectlyWhenLifecycleCallbackIsAlive
  • shouldDeliverFailureDirectlyWhenLifecycleCallbackIsAlive
  • shouldDeliverLogoutDirectlyWhenLifecycleCallbackIsAlive
  • shouldRegisterAsLifecycleObserverOnInit
  • shouldUnregisterLifecycleObserverOnDestroy
  • shouldClearPendingLoginResultOnNewLoginStart
  • shouldClearPendingLogoutResultOnNewLogoutStart
  • shouldDeliverPendingLoginResultOnAttach
  • shouldDeliverPendingLoginFailureOnAttach
  • shouldDeliverPendingLogoutResultOnAttach
  • shouldDeliverPendingLogoutFailureOnAttach
  • shouldRegisterLoginCallbackForProcessDeathOnAttach
  • shouldAutoRemoveLoginCallbackOnDestroyAfterAttach
  • shouldNotDeliverLogoutResultWhenNoLogoutCallbackPassedToAttach

All existing unit tests pass without modification.

Manual testing performed on device (Android 14):

  • Scenario 1 — Normal login (no rotation): result delivered directly via callback ✅
  • Scenario 2 — Rotate during login (callback path): result cached → recovered via consumePendingLoginResult() in onResume() ✅
  • Scenario 3 — Rotate during login (await() coroutine path): coroutine continuation survives rotation, result delivered without consumePending ✅
  • Scenario 4 — Process death during login: state restored from Bundle via OAuthManager.fromState(), result delivered via addCallback listeners ✅
  • Scenario 5 — Rotate during logout (callback path): result cached → recovered via consumePendingLogoutResult() in onResume() ✅
  • Scenario 6 — Rotate then cancel login: cancellation cached → consumePendingLoginResult() delivers the error correctly ✅
  • Scenario 7 — Normal logout (no rotation): result delivered directly ✅

Checklist

@utkrishtsahu utkrishtsahu requested a review from a team as a code owner March 24, 2026 14:21
…ry leak and lost results

- Add LifecycleAwareCallback to null the user callback on onDestroy, eliminating the Activity memory leak during rotation
- Cache results arriving after destroy in AtomicReference (pendingLoginResult / pendingLogoutResult) for recovery via consumePendingLoginResult / consumePendingLogoutResult in onResume
- Revert OAuthManager and LogoutManager to hold a strong callback reference (leak prevention is now LifecycleAwareCallback's responsibility)
- Add 19 unit tests covering config-change caching, double-consume protection, observer lifecycle, and stale-result clearing
…nfiguration-changes-correctly-on-device-rotation-and-causes-a-memory-leak
* @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

* 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


@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

* @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

private const val KEY_BUNDLE_OAUTH_MANAGER_STATE = "oauth_manager_state"

private val callbacks = CopyOnWriteArraySet<Callback<Credentials, AuthenticationException>>()
internal val callbacks = CopyOnWriteArraySet<Callback<Credentials, 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.

Why is this internal now?

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.

callback was registered after registerCallbacks() or removed after onDestroy). Since internal is module-scoped, it's not visible to SDK consumers.

data class Failure(val error: AuthenticationException) : PendingResult<Nothing>()
}

internal val pendingLoginResult = AtomicReference<PendingResult<Credentials>?>(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.

These 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

loginCallback: Callback<Credentials, AuthenticationException>,
logoutCallback: Callback<Void?, AuthenticationException>? = null,
) {
// Process-death recovery: register and auto-remove on destroy
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 will attach callbacks on each onResume call in the the client application.
Ideally this method should be invoked in the onCreate to avoid duplicate addition and we are clearing all references in the onDestroy. We should listen to the onResume callback and have the pendingLoginResult code triggered from there

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

* @param logoutCallback receives logout results recovered after a configuration change
*/
@JvmStatic
public fun attach(
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 this to something more intuituev

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.

remaned registerCallbacks()

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

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

…y, add @volatile to delegateCallback, make login startInternal private
…s on new flow start, make pending fields internal for direct test access
val resultMissing = authenticationIntent.data == null
if (resultMissing) {
setResult(RESULT_CANCELED)
if (!resultMissing) {
Copy link
Copy Markdown
Contributor

@pmathew92 pmathew92 Apr 4, 2026

Choose a reason for hiding this comment

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

How will this change behave when user back presses the browser ? With this change, the AuthenticationActivity stays in an invisible state doing nothing and user might get stuck

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants