sync: a device with no browser no longer strands the flow

Both launches can fail, and the outer catch named only
ActivityNotFoundException — so a SecurityException from a locked-down
profile escaped the LaunchedEffect and took the app down. The failure
was also ignored: onBrowserLaunched cleared openInBrowser regardless, so
the user sat on "waiting for your browser" with a spinner and no browser
for the rest of the twenty-minute window, on exactly the AOSP and
GrapheneOS devices the Custom Tabs fallback exists for.

A failed launch now says so, and the step offers the password path,
which is the only way forward on such a device.
This commit is contained in:
2026-09-09 11:34:50 +02:00
parent b933705c84
commit 1119507585
4 changed files with 33 additions and 6 deletions
@@ -42,6 +42,9 @@ sealed interface AddAccountMessage {
data object BrowserMaintenance : Problem
data object BrowserFailed : Problem
/** No browser could be opened at all — AOSP, GrapheneOS, a locked-down profile. */
data object BrowserUnavailable : Problem
/**
* A home set on a host the credential is not scoped to.
*
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.agendula.ui.accounts
import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.layout.Arrangement
@@ -96,12 +95,19 @@ internal fun AddAccountScreen(
LaunchedEffect(state.openInBrowser) {
val url = state.openInBrowser ?: return@LaunchedEffect
val uri = url.toString().toUri()
try {
// ⚠️ Neither failure may escape, and neither may be ignored. An
// exception out of a LaunchedEffect takes the app down — and the outer
// catch named only ActivityNotFoundException, so a SecurityException
// from a locked-down profile did exactly that. Reporting the failure is
// the other half: clearing openInBrowser regardless left the user on
// "waiting for your browser" with a spinner and no browser, on the very
// devices this fallback exists for.
val launched = runCatching {
CustomTabsIntent.Builder().build().launchUrl(context, uri)
} catch (_: ActivityNotFoundException) {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) }
}
viewModel.onBrowserLaunched()
}.recoverCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
}.isSuccess
if (launched) viewModel.onBrowserLaunched() else viewModel.onBrowserUnavailable()
}
// Backing out abandons a flow that may still be polling the server every two
@@ -349,6 +355,8 @@ private fun AddAccountMessage.text(): String = when (this) {
stringResource(R.string.add_account_browser_error_maintenance)
AddAccountMessage.BrowserFailed ->
stringResource(R.string.add_account_browser_error_failed)
AddAccountMessage.BrowserUnavailable ->
stringResource(R.string.add_account_browser_error_unavailable)
is AddAccountMessage.OutsideCredentialScope ->
stringResource(R.string.add_account_error_cross_domain, host)
is AddAccountMessage.Quirk -> when (quirk) {
@@ -392,6 +392,21 @@ class AddAccountViewModel @Inject constructor(
fun onBrowserLaunched() = _state.update { it.copy(openInBrowser = null) }
/**
* Nothing could open the URL, so there is no approval coming.
*
* ⚠️ Without this, a failed launch still cleared `openInBrowser` and left the
* user watching "waiting for your browser" for the rest of a twenty-minute
* window, on exactly the browserless devices the Custom Tabs fallback exists
* for. Nothing was minted — the flow was never opened — so there is nothing
* to hand back; the poll is stopped because it is polling for an approval
* that cannot happen.
*/
fun onBrowserUnavailable() {
pollJob?.cancel()
browserFailed(AddAccountMessage.BrowserUnavailable)
}
fun onListToggled(url: HttpUrl) {
val step = _state.value.step as? AddAccountStep.ChooseLists ?: return
val selected = if (url in step.selected) step.selected - url else step.selected + url
+1
View File
@@ -348,6 +348,7 @@
<string name="add_account_browser_error_rate_limited">The server is turning away repeated attempts. Wait a few minutes and try again.</string>
<string name="add_account_browser_error_maintenance">The server is in maintenance mode. Try again once it is back.</string>
<string name="add_account_browser_error_failed">The server didn\u2019t finish signing you in. Try again in a moment.</string>
<string name="add_account_browser_error_unavailable">This device has no browser that can open the sign-in page. Use a password instead.</string>
<string name="add_account_quirk_hint_fastmail">Fastmail needs an app password, not your account password \u2014 and CalDAV is not on the Basic plan.</string>
<string name="add_account_quirk_hint_icloud">iCloud needs an app-specific password, which you create at appleid.apple.com with two-factor on.</string>
<string name="add_account_quirk_fastmail">Fastmail needs an app password, and CalDAV is not available on the Basic plan.</string>