From a7b0863c7a3a60fc6794286e101e97802d26eabe Mon Sep 17 00:00:00 2001 From: Markus Goetz Date: Sat, 28 Feb 2026 17:12:45 +0100 Subject: [PATCH] Login: Fix OAuth crash and UX after process death When Android kills the app process while the user is in the browser for OAuth login, the OAuth redirect creates a fresh LoginActivity with no ViewModel state. This caused a MalformedURLException because the token endpoint URL was built from an empty serverBaseUrl. Unlike activity death (where onSaveInstanceState Bundle survives), process death requires SharedPreferences to persist state across the browser redirect. The existing commit feba750ea46 saved PKCE state to SharedPreferences but missed serverBaseUrl. Fix by: - Saving serverBaseUrl and oidcSupported to SharedPreferences alongside the existing PKCE state - On OAuth redirect after process death, re-running OIDC discovery (getServerInfo) to repopulate the ViewModel before processing the authorization code - Always launching FileDisplayActivity after successful account discovery, so the user lands in the app instead of Chrome coming back to foreground --- .../authentication/LoginActivity.kt | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt index 7fd2800a2..b4db83d2e 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt @@ -98,6 +98,8 @@ private const val KEY_OIDC_SUPPORTED = "KEY_OIDC_SUPPORTED" private const val KEY_CODE_VERIFIER = "KEY_CODE_VERIFIER" private const val KEY_CODE_CHALLENGE = "KEY_CODE_CHALLENGE" private const val KEY_OIDC_STATE = "KEY_OIDC_STATE" +private const val KEY_AUTH_SERVER_BASE_URL = "KEY_AUTH_SERVER_BASE_URL" +private const val KEY_AUTH_OIDC_SUPPORTED = "KEY_AUTH_OIDC_SUPPORTED" class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrustedCertListener, SecurityEnforced { @@ -236,14 +238,19 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted if (savedInstanceState == null) { restoreAuthState() } - handleGetAuthorizationCodeResponse(intent) + if (authenticationViewModel.serverInfo.value?.peekContent()?.getStoredData() == null + && ::serverBaseUrl.isInitialized && serverBaseUrl.isNotEmpty()) { + // Process death: serverInfo is gone. Re-fetch it before processing the OAuth response. + // Store the intent as pending — getServerInfoIsSuccess will process it via checkServerType bypass. + pendingAuthorizationIntent = intent + authenticationViewModel.getServerInfo(serverBaseUrl) + } else { + handleGetAuthorizationCodeResponse(intent) + } } - // Process any pending intent that arrived before binding was ready - pendingAuthorizationIntent?.let { - handleGetAuthorizationCodeResponse(it) - pendingAuthorizationIntent = null - } + // Note: pendingAuthorizationIntent is processed in checkServerType() after + // getServerInfo() completes (process death recovery flow). } @@ -261,7 +268,10 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted private fun launchFileDisplayActivity() { val newIntent = Intent(this, FileDisplayActivity::class.java) - newIntent.data = intent.data + if (authenticationViewModel.launchedFromDeepLink) { + newIntent.data = intent.data + } + newIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) startActivity(newIntent) finish() } @@ -295,11 +305,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted authenticationViewModel.accountDiscovery.observe(this) { if (it.peekContent() is UIResult.Success) { notifyDocumentsProviderRoots(applicationContext) - if (authenticationViewModel.launchedFromDeepLink) { - launchFileDisplayActivity() - } else { - finish() - } + launchFileDisplayActivity() } else { binding.authStatusText.run { text = context.getString(R.string.login_account_preparing) @@ -424,6 +430,18 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted } private fun checkServerType(serverInfo: ServerInfo) { + // If we have a pending OAuth response (process death recovery), process it now + // that serverInfo has been re-fetched, instead of starting a new auth flow. + pendingAuthorizationIntent?.let { pendingIntent -> + pendingAuthorizationIntent = null + authTokenType = OAUTH_TOKEN_TYPE + if (serverInfo is ServerInfo.OIDCServer) { + oidcSupported = true + } + handleGetAuthorizationCodeResponse(pendingIntent) + return + } + when (serverInfo) { is ServerInfo.BasicServer -> { authTokenType = BASIC_TOKEN_TYPE @@ -961,6 +979,10 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted putString(KEY_CODE_VERIFIER, authenticationViewModel.codeVerifier) putString(KEY_CODE_CHALLENGE, authenticationViewModel.codeChallenge) putString(KEY_OIDC_STATE, authenticationViewModel.oidcState) + if (::serverBaseUrl.isInitialized) { + putString(KEY_AUTH_SERVER_BASE_URL, serverBaseUrl) + } + putBoolean(KEY_AUTH_OIDC_SUPPORTED, oidcSupported) apply() } } @@ -970,6 +992,8 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted prefs.getString(KEY_CODE_VERIFIER, null)?.let { authenticationViewModel.codeVerifier = it } prefs.getString(KEY_CODE_CHALLENGE, null)?.let { authenticationViewModel.codeChallenge = it } prefs.getString(KEY_OIDC_STATE, null)?.let { authenticationViewModel.oidcState = it } + prefs.getString(KEY_AUTH_SERVER_BASE_URL, null)?.let { serverBaseUrl = it } + oidcSupported = prefs.getBoolean(KEY_AUTH_OIDC_SUPPORTED, false) } private fun clearAuthState() {