Skip to content

Commit feba750

Browse files
zerox80guruz
authored andcommitted
Keep state in LoginActivity in case Android removes it from memory
Based on #60
1 parent 80764e2 commit feba750

File tree

6 files changed

+174
-6
lines changed

6 files changed

+174
-6
lines changed

opencloudApp/src/main/AndroidManifest.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@
242242
android:name=".presentation.authentication.LoginActivity"
243243
android:exported="true"
244244
android:label="@string/login_label"
245-
android:launchMode="singleTask"
245+
android:launchMode="singleTop"
246246
android:theme="@style/Theme.openCloud.Toolbar">
247247
<intent-filter>
248248
<action android:name="android.intent.action.VIEW" />

opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import android.widget.CheckBox
3939
import androidx.appcompat.app.AlertDialog
4040
import androidx.core.content.pm.PackageInfoCompat
4141
import eu.opencloud.android.data.providers.implementation.OCSharedPreferencesProvider
42+
4243
import eu.opencloud.android.datamodel.ThumbnailsCacheManager
4344
import eu.opencloud.android.db.PreferenceManager
4445
import eu.opencloud.android.dependecyinjection.commonModule
@@ -99,6 +100,15 @@ class MainApp : Application() {
99100

100101
appContext = applicationContext
101102

103+
// Ensure Logcat shows Timber logs in debug builds
104+
if (BuildConfig.DEBUG) {
105+
try {
106+
Timber.plant(Timber.DebugTree())
107+
} catch (_: Throwable) {
108+
// ignore if already planted
109+
}
110+
}
111+
102112
startLogsIfEnabled()
103113

104114
DebugInjector.injectDebugTools(appContext)

opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/AuthenticationViewModel.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@ class AuthenticationViewModel(
7272
private val contextProvider: ContextProvider,
7373
) : ViewModel() {
7474

75-
val codeVerifier: String = OAuthUtils().generateRandomCodeVerifier()
76-
val codeChallenge: String = OAuthUtils().generateCodeChallenge(codeVerifier)
77-
val oidcState: String = OAuthUtils().generateRandomState()
75+
var codeVerifier: String = OAuthUtils().generateRandomCodeVerifier()
76+
var codeChallenge: String = OAuthUtils().generateCodeChallenge(codeVerifier)
77+
var oidcState: String = OAuthUtils().generateRandomState()
7878

7979
private val _legacyWebfingerHost = MediatorLiveData<Event<UIResult<String>>>()
8080
val legacyWebfingerHost: LiveData<Event<UIResult<String>>> = _legacyWebfingerHost

opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/LoginActivity.kt

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
9393
import timber.log.Timber
9494
import java.io.File
9595

96+
private const val KEY_SERVER_BASE_URL = "KEY_SERVER_BASE_URL"
97+
private const val KEY_OIDC_SUPPORTED = "KEY_OIDC_SUPPORTED"
98+
private const val KEY_CODE_VERIFIER = "KEY_CODE_VERIFIER"
99+
private const val KEY_CODE_CHALLENGE = "KEY_CODE_CHALLENGE"
100+
private const val KEY_OIDC_STATE = "KEY_OIDC_STATE"
101+
96102
class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrustedCertListener, SecurityEnforced {
97103

98104
private val authenticationViewModel by viewModel<AuthenticationViewModel>()
@@ -112,10 +118,27 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
112118
// For handling AbstractAccountAuthenticator responses
113119
private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null
114120
private var resultBundle: Bundle? = null
121+
private var pendingAuthorizationIntent: Intent? = null
115122

116123
override fun onCreate(savedInstanceState: Bundle?) {
117124
super.onCreate(savedInstanceState)
118125

126+
// Log OAuth redirect details for debugging (especially Firefox issues)
127+
Timber.d("onCreate called with intent data: ${intent.data}, isTaskRoot: $isTaskRoot")
128+
129+
if (intent.data != null && (intent.data?.getQueryParameter("code") != null || intent.data?.getQueryParameter("error") != null)) {
130+
Timber.d("OAuth redirect detected with code or error parameter")
131+
if (!isTaskRoot) {
132+
Timber.d("Not task root, forwarding OAuth redirect to existing LoginActivity instance")
133+
val newIntent = Intent(this, LoginActivity::class.java)
134+
newIntent.data = intent.data
135+
newIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
136+
startActivity(newIntent)
137+
finish()
138+
return
139+
}
140+
}
141+
119142
checkPasscodeEnforced(this)
120143

121144
// Protection against screen recording
@@ -134,6 +157,11 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
134157
authenticationViewModel.supportsOAuth2((userAccount as Account).name)
135158
} else if (savedInstanceState != null) {
136159
authTokenType = savedInstanceState.getString(KEY_AUTH_TOKEN_TYPE)
160+
savedInstanceState.getString(KEY_SERVER_BASE_URL)?.let { serverBaseUrl = it }
161+
oidcSupported = savedInstanceState.getBoolean(KEY_OIDC_SUPPORTED)
162+
savedInstanceState.getString(KEY_CODE_VERIFIER)?.let { authenticationViewModel.codeVerifier = it }
163+
savedInstanceState.getString(KEY_CODE_CHALLENGE)?.let { authenticationViewModel.codeChallenge = it }
164+
savedInstanceState.getString(KEY_OIDC_STATE)?.let { authenticationViewModel.oidcState = it }
137165
}
138166

139167
// UI initialization
@@ -162,6 +190,17 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
162190
binding.accountUsername.setText(username)
163191
}
164192
}
193+
} else {
194+
// Restore UI state
195+
if (::serverBaseUrl.isInitialized && serverBaseUrl.isNotEmpty()) {
196+
binding.hostUrlInput.setText(serverBaseUrl)
197+
198+
if (authTokenType == BASIC_TOKEN_TYPE) {
199+
showOrHideBasicAuthFields(shouldBeVisible = true)
200+
} else if (authTokenType == OAUTH_TOKEN_TYPE) {
201+
showOrHideBasicAuthFields(shouldBeVisible = false)
202+
}
203+
}
165204
}
166205

167206
binding.root.filterTouchesWhenObscured =
@@ -192,10 +231,25 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
192231
accountAuthenticatorResponse?.onRequestContinued()
193232

194233
initLiveDataObservers()
234+
235+
if (intent.data != null && (intent.data?.getQueryParameter("code") != null || intent.data?.getQueryParameter("error") != null)) {
236+
if (savedInstanceState == null) {
237+
restoreAuthState()
238+
}
239+
handleGetAuthorizationCodeResponse(intent)
240+
}
241+
242+
// Process any pending intent that arrived before binding was ready
243+
pendingAuthorizationIntent?.let {
244+
handleGetAuthorizationCodeResponse(it)
245+
pendingAuthorizationIntent = null
246+
}
247+
248+
195249
}
196250

197251
private fun handleDeepLink() {
198-
if (intent.data != null) {
252+
if (intent.data != null && intent.data?.getQueryParameter("code") == null && intent.data?.getQueryParameter("error") == null) {
199253
authenticationViewModel.launchedFromDeepLink = true
200254
if (getAccounts(baseContext).isNotEmpty()) {
201255
launchFileDisplayActivity()
@@ -467,6 +521,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
467521
setResult(Activity.RESULT_OK, intent)
468522

469523
authenticationViewModel.discoverAccount(accountName = accountName, discoveryNeeded = loginAction == ACTION_CREATE)
524+
clearAuthState()
470525
}
471526

472527
private fun loginIsLoading() {
@@ -496,6 +551,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
496551
}
497552
}
498553
}
554+
clearAuthState()
499555
}
500556

501557
/**
@@ -536,6 +592,11 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
536592
val customTabsBuilder: CustomTabsIntent.Builder = CustomTabsIntent.Builder()
537593
val customTabsIntent: CustomTabsIntent = customTabsBuilder.build()
538594

595+
// Add flags to improve compatibility with Firefox and other browsers
596+
// FLAG_ACTIVITY_NEW_TASK ensures the browser opens in a separate task,
597+
// which helps Firefox properly handle the OAuth redirect back to the app
598+
customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
599+
539600
val authorizationEndpointUri = OAuthUtils.buildAuthorizationRequest(
540601
authorizationEndpoint = authorizationEndpoint,
541602
redirectUri = OAuthUtils.buildRedirectUri(applicationContext).toString(),
@@ -551,6 +612,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
551612
)
552613

553614
try {
615+
saveAuthState()
554616
customTabsIntent.launchUrl(
555617
this,
556618
authorizationEndpointUri
@@ -565,6 +627,8 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
565627
override fun onNewIntent(intent: Intent?) {
566628
super.onNewIntent(intent)
567629
intent?.let {
630+
Timber.d("onNewIntent received with data: ${it.data}")
631+
setIntent(it)
568632
handleGetAuthorizationCodeResponse(it)
569633
}
570634
}
@@ -851,6 +915,13 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
851915
override fun onSaveInstanceState(outState: Bundle) {
852916
super.onSaveInstanceState(outState)
853917
outState.putString(KEY_AUTH_TOKEN_TYPE, authTokenType)
918+
if (::serverBaseUrl.isInitialized) {
919+
outState.putString(KEY_SERVER_BASE_URL, serverBaseUrl)
920+
}
921+
outState.putBoolean(KEY_OIDC_SUPPORTED, oidcSupported)
922+
outState.putString(KEY_CODE_VERIFIER, authenticationViewModel.codeVerifier)
923+
outState.putString(KEY_CODE_CHALLENGE, authenticationViewModel.codeChallenge)
924+
outState.putString(KEY_OIDC_STATE, authenticationViewModel.oidcState)
854925
}
855926

856927
override fun finish() {
@@ -871,4 +942,26 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
871942
override fun optionLockSelected(type: LockType) {
872943
manageOptionLockSelected(type)
873944
}
945+
946+
private fun saveAuthState() {
947+
val prefs = getSharedPreferences("auth_state", android.content.Context.MODE_PRIVATE)
948+
prefs.edit().apply {
949+
putString(KEY_CODE_VERIFIER, authenticationViewModel.codeVerifier)
950+
putString(KEY_CODE_CHALLENGE, authenticationViewModel.codeChallenge)
951+
putString(KEY_OIDC_STATE, authenticationViewModel.oidcState)
952+
apply()
953+
}
954+
}
955+
956+
private fun restoreAuthState() {
957+
val prefs = getSharedPreferences("auth_state", android.content.Context.MODE_PRIVATE)
958+
prefs.getString(KEY_CODE_VERIFIER, null)?.let { authenticationViewModel.codeVerifier = it }
959+
prefs.getString(KEY_CODE_CHALLENGE, null)?.let { authenticationViewModel.codeChallenge = it }
960+
prefs.getString(KEY_OIDC_STATE, null)?.let { authenticationViewModel.oidcState = it }
961+
}
962+
963+
private fun clearAuthState() {
964+
val prefs = getSharedPreferences("auth_state", android.content.Context.MODE_PRIVATE)
965+
prefs.edit().clear().apply()
966+
}
874967
}

opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ class ClientManager(
9191
}
9292
} else {
9393
Timber.d("Reusing anonymous client for ${safeClient.baseUri}")
94-
safeClient
94+
safeClient.apply {
95+
credentials = openCloudCredentials
96+
}
9597
}
9698
}
9799

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package eu.opencloud.android.data
2+
3+
import android.accounts.AccountManager
4+
import android.content.Context
5+
import eu.opencloud.android.data.providers.SharedPreferencesProvider
6+
import eu.opencloud.android.lib.common.ConnectionValidator
7+
import io.mockk.mockk
8+
import io.mockk.mockkStatic
9+
import org.junit.Assert.assertEquals
10+
import org.junit.Before
11+
import org.junit.Test
12+
13+
class ClientManagerTest {
14+
15+
private val accountManager: AccountManager = mockk()
16+
private val preferencesProvider: SharedPreferencesProvider = mockk()
17+
private val context: Context = mockk(relaxed = true)
18+
private val connectionValidator: ConnectionValidator = mockk()
19+
private lateinit var clientManager: ClientManager
20+
21+
@Before
22+
fun setUp() {
23+
mockkStatic(android.net.Uri::class)
24+
val uriMock = mockk<android.net.Uri>()
25+
io.mockk.every { android.net.Uri.parse(any()) } returns uriMock
26+
io.mockk.every { uriMock.toString() } returns "https://demo.opencloud.eu"
27+
28+
clientManager = ClientManager(
29+
accountManager,
30+
preferencesProvider,
31+
context,
32+
"eu.opencloud.android.account",
33+
connectionValidator
34+
)
35+
}
36+
37+
@org.junit.After
38+
fun tearDown() {
39+
io.mockk.unmockkStatic(android.net.Uri::class)
40+
}
41+
42+
@Test
43+
fun `getClientForAnonymousCredentials reuses client and resets credentials`() {
44+
val url = "https://demo.opencloud.eu"
45+
val mockClient = mockk<eu.opencloud.android.lib.common.OpenCloudClient>(relaxed = true)
46+
val uriMock = android.net.Uri.parse(url)
47+
48+
io.mockk.every { mockClient.baseUri } returns uriMock
49+
50+
// Inject mock client into clientManager
51+
val field = ClientManager::class.java.getDeclaredField("openCloudClient")
52+
field.isAccessible = true
53+
field.set(clientManager, mockClient)
54+
55+
// Call method - should reuse mockClient
56+
val resultClient = clientManager.getClientForAnonymousCredentials(url, false)
57+
58+
assertEquals("Client should be reused", mockClient, resultClient)
59+
60+
// Verify credentials were set
61+
io.mockk.verify { mockClient.credentials = any() }
62+
}
63+
}

0 commit comments

Comments
 (0)