diff --git a/docs/build-status.md b/docs/build-status.md
index 529241a..671c248 100644
--- a/docs/build-status.md
+++ b/docs/build-status.md
@@ -1054,3 +1054,31 @@ Four consequences that decide whether it is worth it:
What keeps this possible: uploads are already stored byte-for-byte as received, and every index
field is derived in one function (`runs.Put`). The thing to avoid is admin features that *require*
reading content — those would have to be unbuilt later.
+
+### App sign-in, and an undisclosed dependency it surfaced (2026-08-01)
+The app can now sign in to the server's identity provider: authorization code with PKCE, a
+`Sign in` card in settings, and the `echolot://auth` redirect handled alongside the enrolment one
+(told apart by host, since one spends a token and the other completes an authorization).
+
+The detail that decides whether this works on a real phone: **the PKCE verifier is written to
+storage before the browser opens**, not held in memory. Handing control to a browser backgrounds
+the process and Android may kill it; the callback then arrives at a fresh process. An in-memory
+verifier works on a developer's device and fails under memory pressure, which is the worst way for
+a sign-in to break.
+
+Nothing from the IdP is retained. The ID token proves who is signing in, once, and the device
+credential authenticates everything after — no access tokens stored, no refresh tokens rotated.
+
+**A server remains entirely optional.** All eight probes are device-tier; `serverConfigured` gates
+only upload and the account. But answering that question exposed something worth fixing: two probes
+hardcode the reference deployment —
+
+```kotlin
+DnsCanaryProbe(canaryZone = "c.echo-lot.app", ...) // "Hardcoded to the reference deployment"
+StunProbe(serverHost = "fmr-1.echo-lot.app")
+```
+
+so a user with no server of their own still sends DNS and STUN traffic to fmr without being told.
+For a tool that goes to this much trouble over what leaves the device, an undisclosed dependency on
+a third party's infrastructure is the wrong default. It should prefer the configured server, and be
+explicit when there is none. **Open.**
diff --git a/echolot-app/app/src/main/AndroidManifest.xml b/echolot-app/app/src/main/AndroidManifest.xml
index e29224f..130cfdb 100644
--- a/echolot-app/app/src/main/AndroidManifest.xml
+++ b/echolot-app/app/src/main/AndroidManifest.xml
@@ -39,6 +39,17 @@
+
+
+
+
+
+
+
+ // A plain VIEW intent rather than a Custom Tab: the browser is
+ // where the user's existing IdP session already lives, and
+ // androidx.browser would be a dependency for a rounded corner.
+ runCatching {
+ startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url)))
+ }
+ }
+ },
+ onSignOut = vm::signOut,
onEnroll = vm::enroll,
serverStatus = vm.state.archiveStatus,
onBack = { screen = Screen.RUN },
diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt
index 8d54cab..b60040f 100644
--- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt
+++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunViewModel.kt
@@ -215,6 +215,44 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
* history screen has been opened - the two disagreeing read as data loss. */
fun archivedRunCount(): Int = store.list().size
+ private val account = Account(settings)
+
+ /** Name of whoever is signed in on this device, for the settings screen. */
+ var accountName by mutableStateOf(settings.accountName)
+ private set
+
+ /** Starts sign-in; the caller opens the returned URL in a browser. */
+ fun beginSignIn(open: (String) -> Unit) {
+ viewModelScope.launch {
+ state = state.copy(archiveStatus = "contacting the server …")
+ when (val r = withContext(Dispatchers.IO) { account.begin() }) {
+ is Account.SignInStart.Browser -> {
+ state = state.copy(archiveStatus = "continue in your browser …")
+ open(r.url)
+ }
+ is Account.SignInStart.Unavailable ->
+ state = state.copy(archiveStatus = r.reason)
+ }
+ }
+ }
+
+ /** Completes sign-in from the echolot://auth redirect. */
+ fun completeSignIn(callbackUri: String) {
+ viewModelScope.launch {
+ val msg = withContext(Dispatchers.IO) { account.complete(callbackUri) }
+ accountName = settings.accountName
+ state = state.copy(archiveStatus = msg)
+ }
+ }
+
+ fun signOut() {
+ viewModelScope.launch {
+ val msg = withContext(Dispatchers.IO) { account.signOut() }
+ accountName = settings.accountName
+ state = state.copy(archiveStatus = msg)
+ }
+ }
+
/** Redeems an enrollment link, from a paste or from an echolot:// deep link. */
fun enroll(link: String, deviceName: String? = android.os.Build.MODEL) {
viewModelScope.launch {
diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt
index 56f9473..1703ef6 100644
--- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt
+++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/Settings.kt
@@ -104,6 +104,36 @@ class Settings(context: Context) {
val serverConfigured: Boolean
get() = serverUrl.isNotBlank() && serverPin.isNotBlank() && serverCredential.isNotBlank()
+ // ---- account ---------------------------------------------------------------------
+
+ /**
+ * The PKCE verifier and state for a sign-in that is out at the browser.
+ *
+ * Persisted rather than held in memory because handing control to a browser backgrounds this
+ * process, and Android may kill it before the callback returns. An in-memory value works on a
+ * developer's device and fails on a phone under memory pressure.
+ */
+ var pendingVerifier: String
+ get() = prefs.getString(PENDING_VERIFIER, "") ?: ""
+ set(v) = prefs.edit().putString(PENDING_VERIFIER, v).apply()
+
+ var pendingState: String
+ get() = prefs.getString(PENDING_STATE, "") ?: ""
+ set(v) = prefs.edit().putString(PENDING_STATE, v).apply()
+
+ fun clearPendingAuth() = prefs.edit().remove(PENDING_VERIFIER).remove(PENDING_STATE).apply()
+
+ /** Display name of whoever is signed in on this device; empty when nobody is. */
+ var accountName: String
+ get() = prefs.getString(ACCOUNT_NAME, "") ?: ""
+ set(v) = prefs.edit().putString(ACCOUNT_NAME, v).apply()
+
+ var accountId: String
+ get() = prefs.getString(ACCOUNT_ID, "") ?: ""
+ set(v) = prefs.edit().putString(ACCOUNT_ID, v).apply()
+
+ val signedIn: Boolean get() = accountName.isNotBlank()
+
private fun hex(s: String) = ByteArray(s.length / 2) {
((Character.digit(s[it * 2], 16) shl 4) or Character.digit(s[it * 2 + 1], 16)).toByte()
}
@@ -120,5 +150,9 @@ class Settings(context: Context) {
const val SERVER_URL = "server_url"
const val SERVER_PIN = "server_pin"
const val SERVER_CRED = "server_credential"
+ const val PENDING_VERIFIER = "pending_auth_verifier"
+ const val PENDING_STATE = "pending_auth_state"
+ const val ACCOUNT_NAME = "account_name"
+ const val ACCOUNT_ID = "account_id"
}
}
diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt
index f82bb00..6ff9a45 100644
--- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt
+++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt
@@ -49,6 +49,9 @@ fun SettingsScreen(
onDeleteAll: () -> Unit,
onPreviewUpload: () -> Unit,
onCheckServer: () -> Unit,
+ accountName: String,
+ onSignIn: () -> Unit,
+ onSignOut: () -> Unit,
onEnroll: (String) -> Unit,
serverStatus: String?,
onBack: () -> Unit,
@@ -152,6 +155,38 @@ fun SettingsScreen(
}
}
+ // ---- account ----
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Account", style = MaterialTheme.typography.titleMedium)
+ if (accountName.isNotBlank()) {
+ Text("Signed in as $accountName", style = MaterialTheme.typography.bodyMedium)
+ Text(
+ "Runs from every device signed in to this account share one history.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ TextButton(onClick = onSignOut) { Text("Sign out") }
+ } else {
+ Text(
+ "Signing in is optional. It links this device to an account on your " +
+ "server, so several devices share one history — and some servers only " +
+ "accept uploads from a signed-in device.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Button(onClick = onSignIn, enabled = settings.serverConfigured) {
+ Text("Sign in")
+ }
+ if (!settings.serverConfigured) {
+ Text(
+ "Enrol with a server first — the account belongs to the server, not " +
+ "to the app.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ }
+ }
+
// ---- upload ----
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {