Skip to content
Merged
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
35 changes: 33 additions & 2 deletions app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,23 @@ class AuthressLoginClient(
*/
val sessionEstablished: StateFlow<Boolean> = _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> = _authStatus.asStateFlow()

/** The reason the last [authenticate] or [completeAuthenticationRequest] failed, if any. */
private val _authError = MutableStateFlow<String?>(null)
val authError: StateFlow<String?> = _authError.asStateFlow()

init {
// The SDK restores cookies from encrypted storage in its constructor,
// before anything reads a token.
Expand Down Expand Up @@ -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<Unit> = runCatching {
_authError.value = null
_authStatus.value = AuthStatus.OpeningBrowser
storage.setAuthenticationRequest(null)

val codes = JwtManager.getAuthCodes()
Expand Down Expand Up @@ -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 ───────────────────────────────────────

Expand All @@ -168,6 +192,7 @@ class AuthressLoginClient(
* already redeemed.
*/
suspend fun completeAuthenticationRequest(uri: Uri): Result<Unit> = runCatching {
_authStatus.value = AuthStatus.CompletingSignIn
val code = uri.getQueryParameter("code").orEmpty()
val authenticationRequestId = uri.getQueryParameter("authenticationRequestId").orEmpty()

Expand Down Expand Up @@ -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
Expand All @@ -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 =
Expand Down
148 changes: 128 additions & 20 deletions app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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<String?>(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<String?>(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(
Expand All @@ -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 }