diff --git a/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt b/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt index fa92e3a..b97cd3e 100644 --- a/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt +++ b/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt @@ -76,6 +76,23 @@ class AuthressLoginClient( */ val sessionEstablished: StateFlow = _sessionEstablished.asStateFlow() + /** + * Where the sign-in flow currently is, so the UI can show *which* step is + * slow instead of a single spinner covering everything from "tapped + * Continue" to "mailbox loaded". [AwaitingRedirect] can legitimately sit + * for a while (the user is doing something in the browser); the others + * are calls to Authress and are worth a "this is taking a while" hint if + * they don't resolve quickly. + */ + enum class AuthStatus { Idle, OpeningBrowser, AwaitingRedirect, CompletingSignIn } + + private val _authStatus = MutableStateFlow(AuthStatus.Idle) + val authStatus: StateFlow = _authStatus.asStateFlow() + + /** The reason the last [authenticate] or [completeAuthenticationRequest] failed, if any. */ + private val _authError = MutableStateFlow(null) + val authError: StateFlow = _authError.asStateFlow() + init { // The SDK restores cookies from encrypted storage in its constructor, // before anything reads a token. @@ -110,6 +127,8 @@ class AuthressLoginClient( * once the browser has been launched; completion arrives via the deep link. */ suspend fun authenticate(options: AuthenticationOptions = AuthenticationOptions()): Result = runCatching { + _authError.value = null + _authStatus.value = AuthStatus.OpeningBrowser storage.setAuthenticationRequest(null) val codes = JwtManager.getAuthCodes() @@ -157,7 +176,12 @@ class AuthressLoginClient( withContext(Dispatchers.Main) { launchAuthenticationUrl(authenticationUrl) } - }.onFailure { logger.error("Authress", "authenticate() failed", it) } + _authStatus.value = AuthStatus.AwaitingRedirect + }.onFailure { + logger.error("Authress", "authenticate() failed", it) + _authStatus.value = AuthStatus.Idle + _authError.value = it.message + } // ── completeAuthenticationRequest ─────────────────────────────────────── @@ -168,6 +192,7 @@ class AuthressLoginClient( * already redeemed. */ suspend fun completeAuthenticationRequest(uri: Uri): Result = runCatching { + _authStatus.value = AuthStatus.CompletingSignIn val code = uri.getQueryParameter("code").orEmpty() val authenticationRequestId = uri.getQueryParameter("authenticationRequestId").orEmpty() @@ -200,6 +225,7 @@ class AuthressLoginClient( // Code already used — the session is established, nothing to do. storage.setAuthenticationRequest(null) _sessionEstablished.value = getToken() != null + _authStatus.value = AuthStatus.Idle return@runCatching } throw e @@ -208,7 +234,12 @@ class AuthressLoginClient( cookieJar.backupCookies() storage.setAuthenticationRequest(null) _sessionEstablished.value = getToken() != null - }.onFailure { logger.error("Authress", "completeAuthenticationRequest() failed", it) } + _authStatus.value = AuthStatus.Idle + }.onFailure { + logger.error("Authress", "completeAuthenticationRequest() failed", it) + _authStatus.value = AuthStatus.Idle + _authError.value = it.message + } /** True when the redirect belongs to this client. */ fun isRedirect(uri: Uri?): Boolean = diff --git a/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt index b9759cc..2338ba7 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt @@ -2,12 +2,21 @@ package ch.rhosys.email.presentation.auth import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -19,9 +28,26 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import ch.rhosys.email.data.auth.AuthressLoginClient import ch.rhosys.email.di.LocalAppContainer +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +/** + * The steps a sign-in attempt visibly passes through, in order. Shown as a + * checklist instead of a single spinner so a user stuck for a while can see + * *where* it's stuck rather than just that "something" is loading. + */ +private enum class LoginStep(val label: String) { + OpeningBrowser("Opening the sign-in page"), + AwaitingRedirect("Waiting for you to finish in the browser"), + CompletingSignIn("Completing sign-in"), + LoadingMailbox("Loading your mailbox"), +} + +/** How long a step can run before we admit it's taking a while. Waiting on the user in the browser is normal and gets much more slack. */ +private fun LoginStep.slowAfterMillis() = if (this == LoginStep.AwaitingRedirect) 45_000L else 12_000L + /** * Authress-hosted login — social, passkey or password. No credential fields live * in this app; Continue opens the hosted page in a Custom Tab. @@ -34,17 +60,44 @@ import kotlinx.coroutines.launch fun LoginScreen(onSignedIn: () -> Unit) { val container = LocalAppContainer.current val scope = rememberCoroutineScope() - var isLoading by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } + val authStatus by container.authManager.authStatus.collectAsState() + val authError by container.authManager.authError.collectAsState() val hasSession by container.authManager.sessionEstablished.collectAsState() + var mailboxLoading by remember { mutableStateOf(false) } + var mailboxError by remember { mutableStateOf(null) } + var slowHint by remember { mutableStateOf(false) } + + val currentStep = when { + mailboxLoading -> LoginStep.LoadingMailbox + authStatus == AuthressLoginClient.AuthStatus.OpeningBrowser -> LoginStep.OpeningBrowser + authStatus == AuthressLoginClient.AuthStatus.AwaitingRedirect -> LoginStep.AwaitingRedirect + authStatus == AuthressLoginClient.AuthStatus.CompletingSignIn -> LoginStep.CompletingSignIn + else -> null + } + + fun loadMailbox() { + scope.launch { + mailboxLoading = true + mailboxError = null + runCatching { container.accountRepository.refresh() } + .onFailure { mailboxError = it.message ?: "Couldn't load your mailbox" } + mailboxLoading = false + if (mailboxError == null) onSignedIn() + } + } + LaunchedEffect(hasSession) { - if (!hasSession) return@LaunchedEffect - isLoading = true - runCatching { container.accountRepository.refresh() } - isLoading = false - onSignedIn() + if (hasSession) loadMailbox() + } + + // Resets on every step change, then flips on after that step's own grace period. + LaunchedEffect(currentStep) { + slowHint = false + val step = currentStep ?: return@LaunchedEffect + delay(step.slowAfterMillis()) + slowHint = true } Column( @@ -58,25 +111,80 @@ fun LoginScreen(onSignedIn: () -> Unit) { style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 8.dp, bottom = 24.dp), ) - if (isLoading) { - CircularProgressIndicator() - } else { + + if (currentStep == null) { Button(onClick = { - isLoading = true - error = null - scope.launch { - container.authManager.authenticate() - .onFailure { - isLoading = false - error = it.message - } - } + mailboxError = null + scope.launch { container.authManager.authenticate() } }) { Text("Continue") } + } else { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(20.dp)) + LoginStepList(current = currentStep) + + if (slowHint) { + Text( + "This is taking longer than expected.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 16.dp), + ) + TextButton(onClick = { + if (currentStep == LoginStep.LoadingMailbox) loadMailbox() else { + mailboxError = null + scope.launch { container.authManager.authenticate() } + } + }) { + Text("Try again") + } + } } - error?.let { + + (authError ?: mailboxError)?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 16.dp)) } } } + +@Composable +private fun LoginStepList(current: LoginStep) { + Column { + LoginStep.entries.forEach { step -> + val state = when { + step.ordinal < current.ordinal -> StepState.DONE + step == current -> StepState.ACTIVE + else -> StepState.PENDING + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp), + ) { + when (state) { + StepState.DONE -> Icon( + Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + StepState.ACTIVE -> CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + StepState.PENDING -> Icon( + Icons.Filled.RadioButtonUnchecked, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + Text( + step.label, + style = MaterialTheme.typography.bodyMedium, + color = if (state == StepState.PENDING) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 10.dp), + ) + } + } + } +} + +private enum class StepState { DONE, ACTIVE, PENDING }