Skip to content

Conversation

@zerox80
Copy link
Contributor

@zerox80 zerox80 commented Nov 21, 2025

Fix: Critical Login State Loss & Authentication Issues

📋 Overview

This PR resolves critical issues in the login process, specifically regarding OAuth authentication (OIDC). The primary issue was that the LoginActivity state (and thus crucial authentication parameters) was lost when the user switched to the browser view (Custom Tabs) and returned. This resulted in login failures or app crashes/restarts, particularly on devices with aggressive memory management.

🐛 The Problem

When the app opens the browser for OAuth login, LoginActivity is pushed to the background. Android may terminate the app process at this point to reclaim memory.
When the user returns after logging in:

  1. LoginActivity is recreated (onCreate).
  2. All in-memory variables (like codeVerifier, codeChallenge, serverBaseUrl) are gone.
  3. The app no longer knows where to connect or how to verify the received "Auth Code".
  4. Result: Login fails or the app returns to the start screen (URL input field), forcing the user to re-enter the URL even though they had already done so. This commonly occurred when switching apps, for example, to copy credentials from a Password Manager.

🛠 The Solution

The solution consists of three main components:

  1. Persistent Storage (SharedPreferences): Critical OAuth parameters (codeVerifier, codeChallenge, oidcState) are now explicitly saved to SharedPreferences before the browser is opened. This is more robust than onSaveInstanceState as it survives complete process restarts.
  2. State Restoration: In onCreate and onSaveInstanceState, the UI state (URL input, Auth method) is now correctly saved and restored.
  3. Intent Handling (SingleTop): Prevents a new instance of LoginActivity from being placed on the stack when the browser returns. Instead, the existing instance is reused.

🔍 Detailed Code Analysis

1. AuthenticationViewModel.kt - Mutability for Restore

We need to make the OAuth parameters mutable so we can overwrite them when restoring the old state from storage.

// BEFORE: Immutable values (val), generated when the class was created
// val codeVerifier: String = OAuthUtils().generateRandomCodeVerifier()

// AFTER: Mutable values (var), so we can overwrite them
// when restoring the old state.
var codeVerifier: String = OAuthUtils().generateRandomCodeVerifier()
var codeChallenge: String = OAuthUtils().generateCodeChallenge(codeVerifier)
var oidcState: String = OAuthUtils().generateRandomState()

2. LoginActivity.kt - Intent & Task Management

When the browser returns with the Auth Code, we don't want Android to layer a new LoginActivity over the old one. We want to reactivate the old one.

override fun onCreate(savedInstanceState: Bundle?) {
    // Check if we are returning via a Redirect (Deep Link) with an Auth Code
    if (intent.data != null && (intent.data?.getQueryParameter("code") != null || intent.data?.getQueryParameter("error") != null)) {
        if (!isTaskRoot) {
            // IMPORTANT: If we are not the root activity, bring the existing instance to the front.
            // FLAG_ACTIVITY_CLEAR_TOP: Removes everything that might be above LoginActivity.
            // FLAG_ACTIVITY_SINGLE_TOP: Uses the existing instance (calls onNewIntent) instead of creating a new one.
            val newIntent = Intent(this, LoginActivity::class.java)
            newIntent.data = intent.data
            newIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
            startActivity(newIntent)
            finish()
            return
        }
    }
    super.onCreate(savedInstanceState)
    // ...

3. LoginActivity.kt - Saving & Loading Auth State

Here lies the magic of persistence. We don't just rely on RAM.

Saving before Browser Launch:

private fun startOAuthAuthorization(...) {
    // ...
    try {
        // BEFORE opening the browser, we save everything securely to "disk" (SharedPreferences).
        saveAuthState() 
        customTabsIntent.launchUrl(this, authorizationEndpointUri)
    } catch (e: Exception) { ... }
}

private fun saveAuthState() {
    val prefs = getSharedPreferences("auth_state", android.content.Context.MODE_PRIVATE)
    prefs.edit().apply {
        // We save the cryptographic keys needed to verify the login.
        putString(KEY_CODE_VERIFIER, authenticationViewModel.codeVerifier)
        putString(KEY_CODE_CHALLENGE, authenticationViewModel.codeChallenge)
        putString(KEY_OIDC_STATE, authenticationViewModel.oidcState)
        apply()
    }
}

Restoring on Start (onCreate):

// In onCreate...
} else {
    // If savedInstanceState exists (e.g., screen rotated or restored by system)
    authTokenType = savedInstanceState.getString(KEY_AUTH_TOKEN_TYPE)
    
    // Restore Server URL so the input field isn't empty
    savedInstanceState.getString(KEY_SERVER_BASE_URL)?.let { serverBaseUrl = it }
    oidcSupported = savedInstanceState.getBoolean(KEY_OIDC_SUPPORTED)

    // Restore ViewModel data from Bundle
    savedInstanceState.getString(KEY_CODE_VERIFIER)?.let { authenticationViewModel.codeVerifier = it }
    // ...
}

// ... further down in onCreate ...

// If we return with an Auth Code (Intent Data has "code")
if (intent.data != null && (intent.data?.getQueryParameter("code") != null ...)) {
    // If savedInstanceState is null (e.g., process was completely dead),
    // we try to rescue the state from SharedPreferences.
    if (savedInstanceState == null) {
        restoreAuthState()
    }
    handleGetAuthorizationCodeResponse(intent)
}

Cleanup on Success:

// If login was successful, we delete the temporary data.
private fun onAccountCreationSuccessful(...) {
    // ...
    clearAuthState()
}

4. LoginActivity.kt - UI State Restoration

We ensure that the UI (input fields, button visibility) is correctly restored so the user isn't faced with an empty screen.

// Restore UI state
if (::serverBaseUrl.isInitialized && serverBaseUrl.isNotEmpty()) {
    binding.hostUrlInput.setText(serverBaseUrl)
    
    // Show correct fields (Basic Auth vs. OAuth) based on previous state
    if (authTokenType == BASIC_TOKEN_TYPE) {
        showOrHideBasicAuthFields(shouldBeVisible = true)
    } else if (authTokenType == OAUTH_TOKEN_TYPE) {
        showOrHideBasicAuthFields(shouldBeVisible = false)
    }
}

✅ Verification / Testing

  1. Scenario: Simulate Process Death
    • Open App -> Enter Server URL -> Click "Connect".
    • Browser opens for login.
    • Now: Switch to a different App (Password Manager)
    • Return to browser -> Complete login.
    • Expectation: App opens, does NOT crash, login completes successfully.
  2. Scenario: Normal Flow
    • Standard login flow without interruption.
    • Expectation: Works as before, no regressions.

Notice: I utilized Gemini 3 Pro to accelerate the development of this PR. Therefore, I consider a single-person review (2-eye principle) insufficient. I strictly prefer a 4-eye principle review by another contributor to ensure correctness.

@zerox80
Copy link
Contributor Author

zerox80 commented Nov 22, 2025

Notice: I utilized Gemini 3 Pro to accelerate the development of this PR. Therefore, I consider a single-person review (2-eye principle) insufficient. I strictly prefer a 4-eye principle review by another contributor to ensure correctness.

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.

1 participant