Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion opencloudApp/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
Expand Down
10 changes: 10 additions & 0 deletions opencloudApp/src/main/java/eu/opencloud/android/MainApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Event<UIResult<String>>>()
val legacyWebfingerHost: LiveData<Event<UIResult<String>>> = _legacyWebfingerHost
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthenticationViewModel>()
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -496,6 +551,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
}
}
}
clearAuthState()
}

/**
Expand Down Expand Up @@ -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(),
Expand All @@ -551,6 +612,7 @@ class LoginActivity : AppCompatActivity(), SslUntrustedCertDialog.OnSslUntrusted
)

try {
saveAuthState()
customTabsIntent.launchUrl(
this,
authorizationEndpointUri
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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() {
Expand All @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ class ClientManager(
}
} else {
Timber.d("Reusing anonymous client for ${safeClient.baseUri}")
safeClient
safeClient.apply {
credentials = openCloudCredentials
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<android.net.Uri>()
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<eu.opencloud.android.lib.common.OpenCloudClient>(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() }
}
}
Loading