diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountMessage.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountMessage.kt
index 7d044ca..c1f950a 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountMessage.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountMessage.kt
@@ -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.
*
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountScreen.kt
index 3db6cbd..c88d746 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountScreen.kt
@@ -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) {
diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountViewModel.kt
index af48af0..0973cd2 100644
--- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/accounts/AddAccountViewModel.kt
@@ -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
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 92c9a9d..1967390 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -348,6 +348,7 @@
The server is turning away repeated attempts. Wait a few minutes and try again.
The server is in maintenance mode. Try again once it is back.
The server didn\u2019t finish signing you in. Try again in a moment.
+ This device has no browser that can open the sign-in page. Use a password instead.
Fastmail needs an app password, not your account password \u2014 and CalDAV is not on the Basic plan.
iCloud needs an app-specific password, which you create at appleid.apple.com with two-factor on.
Fastmail needs an app password, and CalDAV is not available on the Basic plan.