diff --git a/opencloudApp/src/main/AndroidManifest.xml b/opencloudApp/src/main/AndroidManifest.xml index 93edc6257..0d6ba96aa 100644 --- a/opencloudApp/src/main/AndroidManifest.xml +++ b/opencloudApp/src/main/AndroidManifest.xml @@ -242,7 +242,7 @@ android:name=".presentation.authentication.LoginActivity" android:exported="true" android:label="@string/login_label" - android:launchMode="singleTask" + android:launchMode="singleTop" android:theme="@style/Theme.openCloud.Toolbar"> diff --git a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt index 4ac0b4d0f..4897b67c8 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt @@ -39,6 +39,7 @@ import android.widget.CheckBox import androidx.appcompat.app.AlertDialog import androidx.core.content.pm.PackageInfoCompat import eu.opencloud.android.data.providers.implementation.OCSharedPreferencesProvider + import eu.opencloud.android.datamodel.ThumbnailsCacheManager import eu.opencloud.android.db.PreferenceManager import eu.opencloud.android.dependecyinjection.commonModule @@ -99,6 +100,15 @@ class MainApp : Application() { appContext = applicationContext + // Ensure Logcat shows Timber logs in debug builds + if (BuildConfig.DEBUG) { + try { + Timber.plant(Timber.DebugTree()) + } catch (_: Throwable) { + // ignore if already planted + } + } + startLogsIfEnabled() DebugInjector.injectDebugTools(appContext) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/AuthenticationViewModel.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/AuthenticationViewModel.kt index 03ad1a092..88b03f216 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/AuthenticationViewModel.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/authentication/AuthenticationViewModel.kt @@ -72,9 +72,9 @@ class AuthenticationViewModel( private val contextProvider: ContextProvider, ) : ViewModel() { - val codeVerifier: String = OAuthUtils().generateRandomCodeVerifier() - val codeChallenge: String = OAuthUtils().generateCodeChallenge(codeVerifier) - val oidcState: String = OAuthUtils().generateRandomState() + var codeVerifier: String = OAuthUtils().generateRandomCodeVerifier() + var codeChallenge: String = OAuthUtils().generateCodeChallenge(codeVerifier) + var oidcState: String = OAuthUtils().generateRandomState() private val _legacyWebfingerHost = MediatorLiveData>>() val legacyWebfingerHost: LiveData>> = _legacyWebfingerHost 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 57ded5f90..4e1819a0a 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 @@ -93,6 +93,12 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import timber.log.Timber import java.io.File +private const val KEY_SERVER_BASE_URL = "KEY_SERVER_BASE_URL" +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" + class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrustedCertListener, SecurityEnforced { private val authenticationViewModel by viewModel() @@ -112,10 +118,27 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted // For handling AbstractAccountAuthenticator responses private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null private var resultBundle: Bundle? = null + private var pendingAuthorizationIntent: Intent? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Log OAuth redirect details for debugging (especially Firefox issues) + Timber.d("onCreate called with intent data: ${intent.data}, isTaskRoot: $isTaskRoot") + + if (intent.data != null && (intent.data?.getQueryParameter("code") != null || intent.data?.getQueryParameter("error") != null)) { + Timber.d("OAuth redirect detected with code or error parameter") + if (!isTaskRoot) { + Timber.d("Not task root, forwarding OAuth redirect to existing LoginActivity instance") + 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 + } + } + checkPasscodeEnforced(this) // Protection against screen recording @@ -134,6 +157,11 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted authenticationViewModel.supportsOAuth2((userAccount as Account).name) } else if (savedInstanceState != null) { authTokenType = savedInstanceState.getString(KEY_AUTH_TOKEN_TYPE) + savedInstanceState.getString(KEY_SERVER_BASE_URL)?.let { serverBaseUrl = it } + oidcSupported = savedInstanceState.getBoolean(KEY_OIDC_SUPPORTED) + savedInstanceState.getString(KEY_CODE_VERIFIER)?.let { authenticationViewModel.codeVerifier = it } + savedInstanceState.getString(KEY_CODE_CHALLENGE)?.let { authenticationViewModel.codeChallenge = it } + savedInstanceState.getString(KEY_OIDC_STATE)?.let { authenticationViewModel.oidcState = it } } // UI initialization @@ -162,6 +190,17 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted binding.accountUsername.setText(username) } } + } else { + // Restore UI state + if (::serverBaseUrl.isInitialized && serverBaseUrl.isNotEmpty()) { + binding.hostUrlInput.setText(serverBaseUrl) + + if (authTokenType == BASIC_TOKEN_TYPE) { + showOrHideBasicAuthFields(shouldBeVisible = true) + } else if (authTokenType == OAUTH_TOKEN_TYPE) { + showOrHideBasicAuthFields(shouldBeVisible = false) + } + } } binding.root.filterTouchesWhenObscured = @@ -192,10 +231,25 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted accountAuthenticatorResponse?.onRequestContinued() initLiveDataObservers() + + if (intent.data != null && (intent.data?.getQueryParameter("code") != null || intent.data?.getQueryParameter("error") != null)) { + if (savedInstanceState == null) { + restoreAuthState() + } + handleGetAuthorizationCodeResponse(intent) + } + + // Process any pending intent that arrived before binding was ready + pendingAuthorizationIntent?.let { + handleGetAuthorizationCodeResponse(it) + pendingAuthorizationIntent = null + } + + } private fun handleDeepLink() { - if (intent.data != null) { + if (intent.data != null && intent.data?.getQueryParameter("code") == null && intent.data?.getQueryParameter("error") == null) { authenticationViewModel.launchedFromDeepLink = true if (getAccounts(baseContext).isNotEmpty()) { launchFileDisplayActivity() @@ -467,6 +521,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted setResult(Activity.RESULT_OK, intent) authenticationViewModel.discoverAccount(accountName = accountName, discoveryNeeded = loginAction == ACTION_CREATE) + clearAuthState() } private fun loginIsLoading() { @@ -496,6 +551,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted } } } + clearAuthState() } /** @@ -536,6 +592,11 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted val customTabsBuilder: CustomTabsIntent.Builder = CustomTabsIntent.Builder() val customTabsIntent: CustomTabsIntent = customTabsBuilder.build() + // Add flags to improve compatibility with Firefox and other browsers + // FLAG_ACTIVITY_NEW_TASK ensures the browser opens in a separate task, + // which helps Firefox properly handle the OAuth redirect back to the app + customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val authorizationEndpointUri = OAuthUtils.buildAuthorizationRequest( authorizationEndpoint = authorizationEndpoint, redirectUri = OAuthUtils.buildRedirectUri(applicationContext).toString(), @@ -551,6 +612,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted ) try { + saveAuthState() customTabsIntent.launchUrl( this, authorizationEndpointUri @@ -565,6 +627,8 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) intent?.let { + Timber.d("onNewIntent received with data: ${it.data}") + setIntent(it) handleGetAuthorizationCodeResponse(it) } } @@ -851,6 +915,13 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString(KEY_AUTH_TOKEN_TYPE, authTokenType) + if (::serverBaseUrl.isInitialized) { + outState.putString(KEY_SERVER_BASE_URL, serverBaseUrl) + } + outState.putBoolean(KEY_OIDC_SUPPORTED, oidcSupported) + outState.putString(KEY_CODE_VERIFIER, authenticationViewModel.codeVerifier) + outState.putString(KEY_CODE_CHALLENGE, authenticationViewModel.codeChallenge) + outState.putString(KEY_OIDC_STATE, authenticationViewModel.oidcState) } override fun finish() { @@ -871,4 +942,26 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted override fun optionLockSelected(type: LockType) { manageOptionLockSelected(type) } + + private fun saveAuthState() { + val prefs = getSharedPreferences("auth_state", android.content.Context.MODE_PRIVATE) + prefs.edit().apply { + putString(KEY_CODE_VERIFIER, authenticationViewModel.codeVerifier) + putString(KEY_CODE_CHALLENGE, authenticationViewModel.codeChallenge) + putString(KEY_OIDC_STATE, authenticationViewModel.oidcState) + apply() + } + } + + private fun restoreAuthState() { + val prefs = getSharedPreferences("auth_state", android.content.Context.MODE_PRIVATE) + 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 } + } + + private fun clearAuthState() { + val prefs = getSharedPreferences("auth_state", android.content.Context.MODE_PRIVATE) + prefs.edit().clear().apply() + } } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt b/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt index e2356ff11..9d8eb0256 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/ClientManager.kt @@ -91,7 +91,9 @@ class ClientManager( } } else { Timber.d("Reusing anonymous client for ${safeClient.baseUri}") - safeClient + safeClient.apply { + credentials = openCloudCredentials + } } } diff --git a/opencloudData/src/test/java/eu/opencloud/android/data/ClientManagerTest.kt b/opencloudData/src/test/java/eu/opencloud/android/data/ClientManagerTest.kt new file mode 100644 index 000000000..8b75fb06b --- /dev/null +++ b/opencloudData/src/test/java/eu/opencloud/android/data/ClientManagerTest.kt @@ -0,0 +1,63 @@ +package eu.opencloud.android.data + +import android.accounts.AccountManager +import android.content.Context +import eu.opencloud.android.data.providers.SharedPreferencesProvider +import eu.opencloud.android.lib.common.ConnectionValidator +import io.mockk.mockk +import io.mockk.mockkStatic +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class ClientManagerTest { + + private val accountManager: AccountManager = mockk() + private val preferencesProvider: SharedPreferencesProvider = mockk() + private val context: Context = mockk(relaxed = true) + private val connectionValidator: ConnectionValidator = mockk() + private lateinit var clientManager: ClientManager + + @Before + fun setUp() { + mockkStatic(android.net.Uri::class) + val uriMock = mockk() + io.mockk.every { android.net.Uri.parse(any()) } returns uriMock + io.mockk.every { uriMock.toString() } returns "https://demo.opencloud.eu" + + clientManager = ClientManager( + accountManager, + preferencesProvider, + context, + "eu.opencloud.android.account", + connectionValidator + ) + } + + @org.junit.After + fun tearDown() { + io.mockk.unmockkStatic(android.net.Uri::class) + } + + @Test + fun `getClientForAnonymousCredentials reuses client and resets credentials`() { + val url = "https://demo.opencloud.eu" + val mockClient = mockk(relaxed = true) + val uriMock = android.net.Uri.parse(url) + + io.mockk.every { mockClient.baseUri } returns uriMock + + // Inject mock client into clientManager + val field = ClientManager::class.java.getDeclaredField("openCloudClient") + field.isAccessible = true + field.set(clientManager, mockClient) + + // Call method - should reuse mockClient + val resultClient = clientManager.getClientForAnonymousCredentials(url, false) + + assertEquals("Client should be reused", mockClient, resultClient) + + // Verify credentials were set + io.mockk.verify { mockClient.credentials = any() } + } +}