diff --git a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt index eb3e98a6c570..08c9fc481608 100644 --- a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt @@ -12,6 +12,9 @@ import androidx.lifecycle.ViewModelProvider import com.nextcloud.client.documentscan.DocumentScanViewModel import com.nextcloud.client.etm.EtmViewModel import com.nextcloud.client.logger.ui.LogsViewModel +import com.nextcloud.client.login.repository.LoginRepository +import com.nextcloud.client.login.repository.LoginRepositoryImpl +import com.nextcloud.client.login.LoginViewModel import com.nextcloud.ui.fileactions.FileActionsViewModel import com.owncloud.android.ui.preview.pdf.PreviewPdfViewModel import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsViewModel @@ -57,6 +60,14 @@ abstract class ViewModelModule { @ViewModelKey(TrashbinFileActionsViewModel::class) abstract fun trashbinFileActionsViewModel(vm: TrashbinFileActionsViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(LoginViewModel::class) + abstract fun loginViewModel(vm: LoginViewModel): ViewModel + + @Binds + abstract fun loginRepository(api: LoginRepositoryImpl): LoginRepository + @Binds abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory } diff --git a/app/src/main/java/com/nextcloud/client/login/LoginViewModel.kt b/app/src/main/java/com/nextcloud/client/login/LoginViewModel.kt new file mode 100644 index 000000000000..f69eb8f08268 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/login/LoginViewModel.kt @@ -0,0 +1,152 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.login + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewModelScope +import com.nextcloud.client.di.IoDispatcher +import com.nextcloud.client.login.model.LoginFailure +import com.nextcloud.client.login.model.LoginResponse +import com.nextcloud.client.login.model.LoginSession +import com.nextcloud.client.login.model.LoginState +import com.nextcloud.client.login.model.LoginStateObserver +import com.nextcloud.client.login.repository.LoginRepository +import com.nextcloud.client.login.util.LoginResponseParser +import com.owncloud.android.authentication.LoginUrlInfo +import com.owncloud.android.lib.common.utils.Log_OC +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +class LoginViewModel @Inject constructor( + private val api: LoginRepository, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher +) : ViewModel() { + + private val _state = MutableStateFlow(LoginState.Idle) + val state: StateFlow = _state.asStateFlow() + + private var flowJob: Job? = null + private var pendingBrowserLaunchUrl: String? = null + private var credentials: LoginUrlInfo? = null + + fun observeState(owner: LifecycleOwner, observer: LoginStateObserver) { + owner.lifecycleScope.launch { + owner.repeatOnLifecycle(Lifecycle.State.STARTED) { + state.collect { observer.onStateChanged(it) } + } + } + } + + fun start(loginEndpointUrl: String) { + if (loginEndpointUrl.isBlank()) { + _state.value = LoginState.Failed(LoginFailure.EMPTY_SERVER_URL) + return + } + + if (hasOngoingSession()) { + Log_OC.d(TAG, "Login already running, ignoring duplicate start") + return + } + + _state.value = LoginState.RequestingSession + flowJob = viewModelScope.launch { requestSessionAndPoll(loginEndpointUrl) } + } + + fun consumePendingBrowserLaunch(): String? = pendingBrowserLaunchUrl.also { pendingBrowserLaunchUrl = null } + + fun consumeCredentials(): LoginUrlInfo? = credentials.also { credentials = null } + + fun isCompleted(): Boolean = _state.value == LoginState.Completed + + fun reset() { + flowJob?.cancel() + flowJob = null + pendingBrowserLaunchUrl = null + credentials = null + _state.value = LoginState.Idle + } + + private fun hasOngoingSession(): Boolean = when (_state.value) { + LoginState.RequestingSession, LoginState.Completed -> true + is LoginState.AwaitingApproval -> true + else -> false + } + + private suspend fun requestSessionAndPoll(loginEndpointUrl: String) { + val response = request { api.requestSession(loginEndpointUrl) } + if (response == null || response.body.isEmpty()) { + _state.value = LoginState.Failed(LoginFailure.EMPTY_RESPONSE) + return + } + + val session = LoginResponseParser.parseSession(loginEndpointUrl, response.body) + if (session == null) { + _state.value = LoginState.Failed(LoginFailure.MALFORMED_RESPONSE) + return + } + + pendingBrowserLaunchUrl = session.loginUrl + _state.value = LoginState.AwaitingApproval(session) + + val approved = withTimeoutOrNull(POLL_TIMEOUT_MILLIS.milliseconds) { pollUntilApproved(session) } + if (approved == null) { + Log_OC.d(TAG, "Login timed out before the user granted access") + _state.value = LoginState.Failed(LoginFailure.TIMED_OUT) + } + } + + private suspend fun pollUntilApproved(session: LoginSession): LoginUrlInfo { + while (true) { + val response = request { api.poll(session.pollUrl, session.pollToken) } + val polledCredentials = response?.let { LoginResponseParser.parseCredentials(it) } + + if (polledCredentials != null) { + credentials = polledCredentials + _state.value = LoginState.Completed + return polledCredentials + } + + delay(POLL_INTERVAL_MILLIS.milliseconds) + } + } + + private suspend fun request(block: () -> LoginResponse): LoginResponse? { + val result = runCatching { withContext(ioDispatcher) { block() } } + + result.exceptionOrNull()?.let { throwable -> + if (throwable is CancellationException) { + throw throwable + } + Log_OC.d(TAG, "Login request failed, retrying: " + throwable.message) + } + + return result.getOrNull() + } + + companion object { + private const val TAG = "LoginViewModel" + + private const val POLL_INTERVAL_MILLIS = 1000L + + private const val POLL_TIMEOUT_MILLIS = 20 * 60 * 1000L + } +} diff --git a/app/src/main/java/com/nextcloud/client/login/model/LoginFailure.kt b/app/src/main/java/com/nextcloud/client/login/model/LoginFailure.kt new file mode 100644 index 000000000000..5709cc5dd75f --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/login/model/LoginFailure.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.login.model + +enum class LoginFailure { + EMPTY_SERVER_URL, + EMPTY_RESPONSE, + MALFORMED_RESPONSE, + TIMED_OUT +} diff --git a/app/src/main/java/com/nextcloud/client/login/model/LoginPoll.kt b/app/src/main/java/com/nextcloud/client/login/model/LoginPoll.kt new file mode 100644 index 000000000000..062d79b94eb6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/login/model/LoginPoll.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.login.model + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginPoll(val token: String = "", val endpoint: String = "") diff --git a/app/src/main/java/com/nextcloud/client/login/model/LoginResponse.kt b/app/src/main/java/com/nextcloud/client/login/model/LoginResponse.kt new file mode 100644 index 000000000000..20f05e786a35 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/login/model/LoginResponse.kt @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.login.model + +data class LoginResponse(val status: Int, val body: String) diff --git a/app/src/main/java/com/nextcloud/client/login/model/LoginSession.kt b/app/src/main/java/com/nextcloud/client/login/model/LoginSession.kt new file mode 100644 index 000000000000..7a5000965cf0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/login/model/LoginSession.kt @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.login.model + +data class LoginSession(val loginUrl: String, val pollUrl: String, val pollToken: String) diff --git a/app/src/main/java/com/nextcloud/client/login/model/LoginSessionResponse.kt b/app/src/main/java/com/nextcloud/client/login/model/LoginSessionResponse.kt new file mode 100644 index 000000000000..a181fa40c2da --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/login/model/LoginSessionResponse.kt @@ -0,0 +1,13 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.login.model + +import kotlinx.serialization.Serializable + +@Serializable +data class LoginSessionResponse(val login: String = "", val poll: LoginPoll = LoginPoll()) diff --git a/app/src/main/java/com/nextcloud/client/login/model/LoginState.kt b/app/src/main/java/com/nextcloud/client/login/model/LoginState.kt new file mode 100644 index 000000000000..108587344c99 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/login/model/LoginState.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.login.model + +sealed interface LoginState { + data object Idle : LoginState + + data object RequestingSession : LoginState + + data class AwaitingApproval(val session: LoginSession) : LoginState + + data object Completed : LoginState + + data class Failed(val reason: LoginFailure) : LoginState +} diff --git a/app/src/main/java/com/nextcloud/client/login/model/LoginStateObserver.kt b/app/src/main/java/com/nextcloud/client/login/model/LoginStateObserver.kt new file mode 100644 index 000000000000..d5645a85cebf --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/login/model/LoginStateObserver.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.login.model + +fun interface LoginStateObserver { + fun onStateChanged(state: LoginState) +} diff --git a/app/src/main/java/com/nextcloud/client/login/repository/LoginRepository.kt b/app/src/main/java/com/nextcloud/client/login/repository/LoginRepository.kt new file mode 100644 index 000000000000..c9419972e18b --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/login/repository/LoginRepository.kt @@ -0,0 +1,16 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.login.repository + +import com.nextcloud.client.login.model.LoginResponse + +interface LoginRepository { + fun requestSession(loginEndpointUrl: String): LoginResponse + + fun poll(pollUrl: String, token: String): LoginResponse +} diff --git a/app/src/main/java/com/nextcloud/client/login/repository/LoginRepositoryImpl.kt b/app/src/main/java/com/nextcloud/client/login/repository/LoginRepositoryImpl.kt new file mode 100644 index 000000000000..35db2e8afc2a --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/login/repository/LoginRepositoryImpl.kt @@ -0,0 +1,34 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.login.repository + +import com.nextcloud.client.login.model.LoginResponse +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.operations.PostMethod +import okhttp3.FormBody +import javax.inject.Inject + +class LoginRepositoryImpl @Inject constructor(private val clientFactory: ClientFactory) : LoginRepository { + + override fun requestSession(loginEndpointUrl: String): LoginResponse = + PostMethod(loginEndpointUrl, false, FormBody.Builder().build()).send() + + override fun poll(pollUrl: String, token: String): LoginResponse { + val body = FormBody.Builder().add(TOKEN_PARAMETER, token).build() + return PostMethod(pollUrl, false, body).send() + } + + private fun PostMethod.send(): LoginResponse { + val status = execute(clientFactory.createPlainClient()) + return LoginResponse(status, getResponseBodyAsString()) + } + + companion object { + private const val TOKEN_PARAMETER = "token" + } +} diff --git a/app/src/main/java/com/nextcloud/client/login/util/LoginResponseParser.kt b/app/src/main/java/com/nextcloud/client/login/util/LoginResponseParser.kt new file mode 100644 index 000000000000..8ad68d3eb3ba --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/login/util/LoginResponseParser.kt @@ -0,0 +1,43 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.login.util + +import com.nextcloud.client.login.model.LoginSession +import com.nextcloud.client.login.model.LoginSessionResponse +import com.nextcloud.client.login.model.LoginResponse +import com.owncloud.android.authentication.LoginUrlInfo +import kotlinx.serialization.json.Json + +object LoginResponseParser { + + private const val POLL_PATH_SUFFIX = "/poll" + + private val json = Json { ignoreUnknownKeys = true } + + fun parseSession(requestUrl: String, body: String): LoginSession? { + val response = runCatching { json.decodeFromString(body) }.getOrNull() + val loginUrl = response?.login.orEmpty() + val token = response?.poll?.token.orEmpty() + val endpoint = response?.poll?.endpoint?.takeIf { it.isNotEmpty() } ?: (requestUrl + POLL_PATH_SUFFIX) + + return if (loginUrl.isEmpty() || token.isEmpty()) { + null + } else { + LoginSession(loginUrl, endpoint, token) + } + } + + fun parseCredentials(response: LoginResponse): LoginUrlInfo? { + if (response.body.isEmpty()) { + return null + } + + val credentials = runCatching { json.decodeFromString(response.body) }.getOrNull() + return credentials?.takeIf { it.isValid(response.status) } + } +} diff --git a/app/src/main/java/com/owncloud/android/authentication/AuthObject.kt b/app/src/main/java/com/owncloud/android/authentication/AuthObject.kt deleted file mode 100644 index eddaecc42ac4..000000000000 --- a/app/src/main/java/com/owncloud/android/authentication/AuthObject.kt +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2025 Alper Ozturk - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -package com.owncloud.android.authentication - -data class AuthObject(val poll: Poll, val login: String) - -data class Poll(val token: String, val endpoint: String) diff --git a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java index 8caff5691b66..5e43f1e2e748 100644 --- a/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java +++ b/app/src/main/java/com/owncloud/android/authentication/AuthenticatorActivity.java @@ -31,7 +31,6 @@ import android.os.IBinder; import android.preference.PreferenceManager; import android.text.TextUtils; -import android.util.Pair; import android.view.KeyEvent; import android.view.View; import android.view.inputmethod.EditorInfo; @@ -49,8 +48,6 @@ import com.blikoon.qrcodescanner.QrCodeActivity; import com.google.android.material.button.MaterialButton; import com.google.gson.Gson; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; import com.google.gson.reflect.TypeToken; import com.nextcloud.android.common.ui.color.ColorUtil; import com.nextcloud.android.common.ui.theme.utils.ColorRole; @@ -58,12 +55,14 @@ import com.nextcloud.client.account.UserAccountManager; import com.nextcloud.client.device.DeviceInfo; import com.nextcloud.client.di.Injectable; +import com.nextcloud.client.di.ViewModelFactory; +import com.nextcloud.client.login.model.LoginFailure; +import com.nextcloud.client.login.model.LoginState; +import com.nextcloud.client.login.LoginViewModel; import com.nextcloud.client.network.ClientFactory; import com.nextcloud.client.onboarding.FirstRunActivity; import com.nextcloud.client.onboarding.OnboardingService; import com.nextcloud.client.preferences.AppPreferences; -import com.nextcloud.common.PlainClient; -import com.nextcloud.operations.PostMethod; import com.nextcloud.utils.extensions.BundleExtensionsKt; import com.nextcloud.utils.mdm.MDMConfig; import com.owncloud.android.MainApp; @@ -113,10 +112,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; import javax.inject.Inject; @@ -135,12 +131,8 @@ import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; -import androidx.lifecycle.Lifecycle; -import androidx.lifecycle.LifecycleEventObserver; -import androidx.lifecycle.ProcessLifecycleOwner; +import androidx.lifecycle.ViewModelProvider; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import okhttp3.FormBody; -import okhttp3.RequestBody; import static com.owncloud.android.utils.PermissionUtil.PERMISSIONS_CAMERA; @@ -232,17 +224,17 @@ public class AuthenticatorActivity extends AccountAuthenticatorActivity @Inject ViewThemeUtils.Factory viewThemeUtilsFactory; @Inject ColorUtil colorUtil; @Inject ClientFactory clientFactory; + @Inject ViewModelFactory viewModelFactory; - private AuthObject authObject = null; - private String fallbackToken; private boolean onlyAdd = false; - private final Gson gson = new Gson(); + private LoginViewModel loginViewModel; private ViewThemeUtils viewThemeUtils; - private final ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor(); protected LoginDialog loginDialog; + private static final String LOGIN_FLOW_LOG = "LoginFlowV2 |"; + @VisibleForTesting public AccountSetupBinding getAccountSetupBinding() { return accountSetupBinding; @@ -256,6 +248,11 @@ public AccountSetupBinding getAccountSetupBinding() { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + Log_OC.d(TAG, LOGIN_FLOW_LOG + " Lifecycle onCreate (recreated=" + (savedInstanceState != null) + ")"); + + loginViewModel = new ViewModelProvider(this, viewModelFactory).get(LoginViewModel.class); + loginViewModel.observeState(this, this::onLoginFlowStateChanged); + loginDialog = new LoginDialog(this); viewThemeUtils = viewThemeUtilsFactory.withPrimaryAsBackground(); viewThemeUtils.platform.colorStatusBar(this, getResources().getColor(R.color.primary)); @@ -325,7 +322,7 @@ protected void onCreate(Bundle savedInstanceState) { if (webViewLoginMethod) { accountSetupWebviewBinding = AccountSetupWebviewBinding.inflate(getLayoutInflater()); setContentView(accountSetupWebviewBinding.getRoot()); - anonymouslyPostLoginRequest(webloginUrl); + showOrStartLoginFlowV2(webloginUrl); } else { accountSetupBinding = AccountSetupBinding.inflate(getLayoutInflater()); setContentView(accountSetupBinding.getRoot()); @@ -344,8 +341,6 @@ protected void onCreate(Bundle savedInstanceState) { initServerPreFragment(savedInstanceState); } - - ProcessLifecycleOwner.get().getLifecycle().addObserver(lifecycleEventObserver); } private void showEnforcedServers() { @@ -401,90 +396,64 @@ private void deleteCookies() { } // region LoginFlow - private final ScheduledExecutorService loginFlowExecutorService = Executors.newSingleThreadScheduledExecutor(); - private boolean isLoginProcessCompleted = false; - private boolean isRedirectedToTheDefaultBrowser = false; - private String baseUrl; - - private void poolLogin() { - loginFlowExecutorService.scheduleWithFixedDelay(() -> { - if (!isLoginProcessCompleted) { - performLoginFlowV2(); - } - }, 0, 30, TimeUnit.SECONDS); - } - /** - * This function facilitates the login process by anonymously posting a login request to a specified URL. - * After posting the request, it retrieves the login URL for completing the login flow. - * The login flow version used is v2. - * - * @param url The URL where the login request is to be anonymously posted. - * This URL should handle the login request and return the login URL. - * It's typically the entry point for the login process. - * Example: "..." - */ - private void anonymouslyPostLoginRequest(String url) { + private void startLoginFlowV2(String url) { if (TextUtils.isEmpty(url)) { DisplayUtils.showSnackMessage(this, R.string.authenticator_activity_empty_base_url); return; } - baseUrl = url; - singleThreadExecutor.execute(() -> { - String response = getResponseOfAnonymouslyPostLoginRequest(); - if (TextUtils.isEmpty(response)) { - DisplayUtils.showSnackMessage(AuthenticatorActivity.this, R.string.authenticator_activity_empty_response_message); - return; - } + Log_OC.d(TAG, LOGIN_FLOW_LOG + " Step 1: POST /login/v2 -> " + url); + loginViewModel.start(url); + } - String loginUrl = extractLoginUrl(response); - runOnUiThread(() -> { - initLoginInfoView(); - launchDefaultWebBrowser(loginUrl); - }); - }); + private void showOrStartLoginFlowV2(String url) { + if (loginViewModel.getState().getValue() instanceof LoginState.AwaitingApproval) { + showWaitingForBrowserUi(); + return; + } + + startLoginFlowV2(url); } - private String extractLoginUrl(String response) { - try { - authObject = gson.fromJson(response, AuthObject.class); - if (authObject != null && !TextUtils.isEmpty(authObject.getLogin())) { - return authObject.getLogin(); - } else { - Log_OC.e(TAG, "AuthObject parsing failed or login empty, trying JSONObject fallback"); + private void onLoginFlowStateChanged(LoginState state) { + if (state instanceof LoginState.AwaitingApproval) { + showWaitingForBrowserUi(); + } else if (state instanceof LoginState.Completed) { + LoginUrlInfo credentials = loginViewModel.consumeCredentials(); + if (credentials != null) { + completeLoginFlow(credentials); } - } catch (Exception e) { - Log_OC.e(TAG, "Error parsing AuthObject: " + e.getMessage(), e); + } else if (state instanceof LoginState.Failed failed) { + showLoginFlowFailure(failed.getReason()); } + } - try { - String fallbackUrl = getLoginFromJsonObject(response); - if (!TextUtils.isEmpty(fallbackUrl)) { - return fallbackUrl; - } else { - Log_OC.e(TAG, "Fallback JSONObject parsing failed or login empty"); - } - } catch (Exception e) { - Log_OC.e(TAG, "Error parsing fallback JSONObject: " + e.getMessage(), e); + private void showWaitingForBrowserUi() { + if (accountSetupWebviewBinding == null) { + accountSetupWebviewBinding = AccountSetupWebviewBinding.inflate(getLayoutInflater()); + setContentView(accountSetupWebviewBinding.getRoot()); } - Log_OC.e(TAG, "Both AuthObject and fallback parsing failed, returning default login URL"); - DisplayUtils.showSnackMessage(this, R.string.authenticator_activity_login_error); - return getResources().getString(R.string.webview_login_url); - } + initLoginInfoView(); - private String getLoginFromJsonObject(String response) { - JsonObject jsonObject = JsonParser.parseString(response).getAsJsonObject(); - fallbackToken = jsonObject.getAsJsonObject("poll").get("token").getAsString(); - return jsonObject.get("login").getAsString(); + String loginUrl = loginViewModel.consumePendingBrowserLaunch(); + if (loginUrl != null) { + launchDefaultWebBrowser(loginUrl); + } } - private String getResponseOfAnonymouslyPostLoginRequest() { - PostMethod post = new PostMethod(baseUrl, false, new FormBody.Builder().build()); - PlainClient client = clientFactory.createPlainClient(); - post.execute(client); - return post.getResponseBodyAsString(); + private void showLoginFlowFailure(LoginFailure reason) { + Log_OC.d(TAG, LOGIN_FLOW_LOG + " login flow failed: " + reason); + + int message = switch (reason) { + case EMPTY_SERVER_URL -> R.string.authenticator_activity_empty_base_url; + case EMPTY_RESPONSE -> R.string.authenticator_activity_empty_response_message; + case TIMED_OUT -> R.string.authenticator_activity_login_timeout; + case MALFORMED_RESPONSE -> R.string.authenticator_activity_login_error; + }; + + DisplayUtils.showSnackMessage(this, message); } private void launchDefaultWebBrowser(String url) { @@ -499,6 +468,7 @@ private void launchDefaultWebBrowser(String url) { try { int toolbarColor = ContextCompat.getColor(this, R.color.primary); AuthTabIntent authTabIntent = new AuthTabIntent.Builder().setColorScheme(toolbarColor).build(); + Log_OC.d(TAG, LOGIN_FLOW_LOG + " Step 5: launching browser via Auth Tab (scheme=" + loginScheme + ")"); authTabIntent.launch(authTabResultLauncher, uri, loginScheme); return; } catch (Exception e) { @@ -509,6 +479,7 @@ private void launchDefaultWebBrowser(String url) { Intent intent = new Intent(Intent.ACTION_VIEW, uri); PackageManager packageManager = getPackageManager(); if (intent.resolveActivity(packageManager) != null) { + Log_OC.d(TAG, LOGIN_FLOW_LOG + " Step 5 (fallback): launching external browser"); startActivity(intent); return; } @@ -516,84 +487,23 @@ private void launchDefaultWebBrowser(String url) { Log_OC.e(TAG, "External browser launch failed: " + e); } + Log_OC.d(TAG, LOGIN_FLOW_LOG + " Step 5 (failed): no web browser found"); DisplayUtils.showSnackMessage(this, R.string.authenticator_activity_no_web_browser_found); } - private Pair extractPollUrlAndToken() { - if (authObject != null) { - final var poll = authObject.getPoll(); - String pollUrl = poll.getEndpoint(); - String token = poll.getToken(); - - if (TextUtils.isEmpty(pollUrl)) { - Log_OC.e(TAG, "auth object poll url is empty."); - } - if (TextUtils.isEmpty(token)) { - Log_OC.e(TAG, "auth object token is empty."); - } - - if (!TextUtils.isEmpty(pollUrl) && !TextUtils.isEmpty(token)) { - return new Pair<>(pollUrl, token); - } - } - - return new Pair<>(baseUrl + "/poll", fallbackToken); - } + private void completeLoginFlow(LoginUrlInfo loginUrlInfo) { + Log_OC.d(TAG, LOGIN_FLOW_LOG + " credentials polled successfully, continuing with account setup"); - private void performLoginFlowV2() { - final var pollUrlAndToken = extractPollUrlAndToken(); - - RequestBody requestBody = new FormBody.Builder() - .add("token", pollUrlAndToken.second) - .build(); - - PlainClient client = clientFactory.createPlainClient(); - PostMethod post = new PostMethod(pollUrlAndToken.first, false, requestBody); - int status = post.execute(client); - String response = post.getResponseBodyAsString(); - - Log_OC.d(TAG, "performLoginFlowV2 status: " + status); - Log_OC.d(TAG, "performLoginFlowV2 response: " + response); - - if (!response.isEmpty()) { - runOnUiThread(() -> completeLoginFlow(response, status)); + if (accountSetupBinding != null) { + accountSetupBinding.hostUrlInput.setText(""); } - } - - private void completeLoginFlow(String response, int status) { - try { - LoginUrlInfo loginUrlInfo = gson.fromJson(response, LoginUrlInfo.class); - if (loginUrlInfo == null) { - Log_OC.e(TAG, "cannot complete login flow loginUrl is null"); - return; - } - isLoginProcessCompleted = loginUrlInfo.isValid(status); - if (accountSetupBinding != null) { - accountSetupBinding.hostUrlInput.setText(""); - } - - mServerInfo.mBaseUrl = AuthenticatorUrlUtils.INSTANCE.normalizeUrlSuffix(loginUrlInfo.getServer()); - webViewUser = loginUrlInfo.getLoginName(); - webViewPassword = loginUrlInfo.getAppPassword(); - } catch (Exception e) { - Log_OC.d(TAG, "Error completeLoginFlow: " + e); - mServerStatusIcon = R.drawable.ic_alert; - mServerStatusText = getString(R.string.qr_could_not_be_read); - showServerStatus(); - } + mServerInfo.mBaseUrl = AuthenticatorUrlUtils.INSTANCE.normalizeUrlSuffix(loginUrlInfo.getServer()); + webViewUser = loginUrlInfo.getLoginName(); + webViewPassword = loginUrlInfo.getAppPassword(); checkOcServer(); - loginFlowExecutorService.shutdown(); - ProcessLifecycleOwner.get().getLifecycle().removeObserver(lifecycleEventObserver); } - - private final LifecycleEventObserver lifecycleEventObserver = ((lifecycleOwner, event) -> { - if (event == Lifecycle.Event.ON_START && authObject != null && !TextUtils.isEmpty(authObject.getPoll().getToken())) { - Log_OC.d(TAG, "Start poolLogin"); - poolLogin(); - } - }); // endregion @Override @@ -646,6 +556,8 @@ public void onReceivedError(WebView view, WebResourceRequest request, WebResourc } public void parseAndLoginFromWebView(String dataString) { + loginViewModel.reset(); + try { String prefix = getString(R.string.login_data_own_scheme) + PROTOCOL_SUFFIX + "login/"; LoginUrlInfo loginUrlInfo = parseLoginDataUrl(prefix, dataString); @@ -841,6 +753,7 @@ public void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); + Log_OC.d(TAG, LOGIN_FLOW_LOG + " Lifecycle onNewIntent (browser return candidate)"); Log_OC.d(TAG, "onNewIntent()"); if (intent.getBooleanExtra(FirstRunActivity.EXTRA_EXIT, false)) { @@ -854,12 +767,19 @@ protected void onNewIntent(Intent intent) { Uri data = intent.getData(); if (data != null && data.toString().startsWith(getString(R.string.login_data_own_scheme))) { + if (loginViewModel.isCompleted()) { + Log_OC.d(TAG, LOGIN_FLOW_LOG + " deep link ignored, login flow was already completed by polling"); + return; + } + if (!MDMConfig.INSTANCE.multiAccountSupport(this) && accountManager.getAccounts().length == 1) { DisplayUtils.showSnackMessage(this, R.string.no_mutliple_accounts_allowed); finish(); return; } else { + Log_OC.d(TAG, LOGIN_FLOW_LOG + + " Browser returned via onNewIntent deep link (nc://) -> parsing credentials"); parseAndLoginFromWebView(data.toString()); } } @@ -871,6 +791,12 @@ protected void onNewIntent(Intent intent) { } } + @Override + protected void onStart() { + super.onStart(); + Log_OC.d(TAG, LOGIN_FLOW_LOG + " Lifecycle onStart"); + } + @SuppressFBWarnings("ANDROID_WEB_VIEW_JAVASCRIPT") @SuppressLint("SetJavaScriptEnabled") private void initSimpleSignupLogin() { @@ -933,6 +859,7 @@ protected void onResume() { @Override protected void onPause() { + Log_OC.d(TAG, LOGIN_FLOW_LOG + " Lifecycle onPause"); if (mOperationsServiceBinder != null) { mOperationsServiceBinder.removeOperationListener(this); } @@ -947,8 +874,8 @@ protected void onDestroy() { mOperationsServiceBinder = null; } + Log_OC.d(TAG, LOGIN_FLOW_LOG + " Lifecycle onDestroy (isFinishing=" + isFinishing() + ")"); Log_OC.d(TAG, "AuthenticatorActivity onDestroy called"); - singleThreadExecutor.shutdown(); super.onDestroy(); } @@ -1094,17 +1021,12 @@ private void onGetServerInfoFinish(RemoteOperationResult result) { webViewPassword != null && !webViewPassword.isEmpty()) { checkBasicAuthorization(webViewUser, webViewPassword); } else { - accountSetupWebviewBinding = AccountSetupWebviewBinding.inflate(getLayoutInflater()); - setContentView(accountSetupWebviewBinding.getRoot()); - - if (!isLoginProcessCompleted) { - if (!isRedirectedToTheDefaultBrowser) { - anonymouslyPostLoginRequest(mServerInfo.mBaseUrl + WEB_LOGIN); - isRedirectedToTheDefaultBrowser = true; - } else { - initLoginInfoView(); - } + if (accountSetupWebviewBinding == null) { + accountSetupWebviewBinding = AccountSetupWebviewBinding.inflate(getLayoutInflater()); + setContentView(accountSetupWebviewBinding.getRoot()); } + + showOrStartLoginFlowV2(mServerInfo.mBaseUrl + WEB_LOGIN); } } else { updateServerStatusIconAndText(result); @@ -1119,6 +1041,7 @@ private void onGetServerInfoFinish(RemoteOperationResult result) { // region LoginInfoView private void initLoginInfoView() { + Log_OC.d(TAG, LOGIN_FLOW_LOG + " Step 4: 'Waiting for browser' UI shown"); LinearLayout loginFlowLayout = accountSetupWebviewBinding.loginFlowV2.getRoot(); MaterialButton cancelButton = accountSetupWebviewBinding.loginFlowV2.cancelButton; loginFlowLayout.setVisibility(View.VISIBLE); @@ -1137,8 +1060,8 @@ private void initLoginInfoView() { ViewCompat.requestApplyInsets(loginFlowLayout); cancelButton.setOnClickListener(v -> { - loginFlowExecutorService.shutdown(); - ProcessLifecycleOwner.get().getLifecycle().removeObserver(lifecycleEventObserver); + Log_OC.d(TAG, LOGIN_FLOW_LOG + " Polling cancelled by user (cancel button)"); + loginViewModel.reset(); recreate(); }); } @@ -1386,7 +1309,8 @@ public void onAuthenticatorTaskCallback(RemoteOperationResult result) } else { // authorization fail due to client side - probably wrong credentials if (accountSetupWebviewBinding != null) { - anonymouslyPostLoginRequest(mServerInfo.mBaseUrl + WEB_LOGIN); + loginViewModel.reset(); + startLoginFlowV2(mServerInfo.mBaseUrl + WEB_LOGIN); } else { DisplayUtils.showSnackMessage(this, R.string.auth_access_failed, result.getLogMessage(this)); @@ -1564,7 +1488,8 @@ private void startQRScanner() { private final ActivityResultLauncher authTabResultLauncher = AuthTabIntent.registerActivityResultLauncher( this, - result -> Log_OC.d(TAG, "Auth Tab result code: " + result.resultCode) + result -> Log_OC.d(TAG, LOGIN_FLOW_LOG + " Browser returned via Auth Tab (resultCode=" + result.resultCode + + ", hasUri=" + (result.resultUri != null) + ")") ); private final ActivityResultLauncher qrScanResultLauncher = registerForActivityResult( diff --git a/app/src/main/java/com/owncloud/android/authentication/LoginUrlInfo.kt b/app/src/main/java/com/owncloud/android/authentication/LoginUrlInfo.kt index a752d40556c6..9eba7d4b3403 100644 --- a/app/src/main/java/com/owncloud/android/authentication/LoginUrlInfo.kt +++ b/app/src/main/java/com/owncloud/android/authentication/LoginUrlInfo.kt @@ -9,7 +9,9 @@ package com.owncloud.android.authentication import com.nextcloud.model.HTTPStatusCodes +import kotlinx.serialization.Serializable +@Serializable data class LoginUrlInfo(var server: String, var loginName: String, var appPassword: String) { fun isValid(status: Int): Boolean = ( status == HTTPStatusCodes.SUCCESS.code && diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a0f756664301..7de5b90f51ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -445,6 +445,7 @@ Cancel Login Please complete login process in your browser There was an issue processing your login request. Please try again later. + Login was not completed in time. Please try again. Add to favorites Remove from favorites