From 30d9501507b3f3b22d4b1a799964961f7230e437 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sat, 1 Aug 2026 23:42:37 +0200 Subject: [PATCH] app: confirm before an enrollment link replaces an existing one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheme was already registered and the deep link already worked — it enrolled on arrival, with no confirmation. Now that the web UI offers the link as something to follow, that is one tap between a working enrollment and a replaced one, from a page that might be showing a link minted for a different device entirely. Enrolling is not additive: the new credential replaces the old, and on the previous server this device simply stops reporting. So the link is held and the user is asked, with both server URLs named — the question is "which server", and it cannot be answered without seeing both. The dialog says what survives, because that is the part someone hesitates over: uploads already on the old server stay there, runs stored on the phone are untouched, and the device reappears on the new server as a new device rather than carrying its history across. Enrolling also clears the cached canary zone. It describes the old server's deployment, and querying it against the new one would measure somebody else's zone and file the answer under this network. Co-Authored-By: Claude Opus 5 --- .../kotlin/app/echo_lot/app/MainActivity.kt | 29 +++++++++++ .../kotlin/app/echo_lot/app/RunViewModel.kt | 48 ++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt index 207e3f0..3bd25cc 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/MainActivity.kt @@ -75,6 +75,35 @@ class MainActivity : ComponentActivity() { screen = Screen.SETTINGS } } + // Replacing an existing enrollment is asked about, never assumed. Following a + // link from a web page is one tap, and the old credential does not survive it. + vm.state.pendingEnroll?.let { pending -> + androidx.compose.material3.AlertDialog( + onDismissRequest = { vm.cancelEnroll() }, + title = { androidx.compose.material3.Text("Replace this device's server?") }, + text = { + androidx.compose.material3.Text( + "This device is already enrolled with " + + "${pending.currentServer}.\n\n" + + "Enrolling with ${pending.newServer} replaces that. Runs " + + "already uploaded stay where they are, but this device " + + "stops reporting to the old server and appears on the new " + + "one as a new device.\n\n" + + "Runs stored on this phone are not affected." + ) + }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = { vm.confirmEnroll() }) { + androidx.compose.material3.Text("Enroll here") + } + }, + dismissButton = { + androidx.compose.material3.TextButton(onClick = { vm.cancelEnroll() }) { + androidx.compose.material3.Text("Keep current server") + } + }, + ) + } androidx.compose.runtime.LaunchedEffect(authUri) { if (authUri != null) { vm.completeSignIn(authUri) 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 09e8db2..8375415 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 @@ -42,6 +42,8 @@ data class UiState( val archiveStatus: String? = null, /** History, newest first. Refreshed after every run and whenever the history screen opens. */ val history: List = emptyList(), + /** An enrollment link waiting on confirmation, because this device is already enrolled. */ + val pendingEnroll: PendingEnroll? = null, /** Shell-tier readiness, shown before a run; null message = say nothing (Shizuku not installed). */ val shizukuNotice: String? = null, val shizukuReady: Boolean = false, @@ -49,6 +51,15 @@ data class UiState( val shizukuState: ShizukuAvailability.State = ShizukuAvailability.State.NOT_INSTALLED, ) +/** + * An enrollment link that would replace an existing one, held until the user says so. + * + * Enrolling is not additive: the new credential replaces the old, and on the previous server this + * device simply stops reporting. Following a link is one tap from a web page, which is not enough + * deliberation to discard a working enrollment by accident. + */ +data class PendingEnroll(val link: String, val currentServer: String, val newServer: String) + /** * Drives one measurement run: device-tier probes (link snapshot, per-network ICMP) always run; * results assemble into a MeasurementDocument with a §7.3 summary. Lives in a ViewModel so a run @@ -255,9 +266,44 @@ class RunViewModel(app: Application) : AndroidViewModel(app) { /** Redeems an enrollment link, from a paste or from an echolot:// deep link. */ fun enroll(link: String, deviceName: String? = android.os.Build.MODEL) { + // Already enrolled? Ask first. The old credential is gone the moment this succeeds, and a + // link followed from a web page is one tap — far too little deliberation for that. + if (settings.serverConfigured) { + val target = app.echo_lot.protocol.EnrollmentLink.parse(link)?.controlUrl ?: link + state = state.copy( + pendingEnroll = PendingEnroll( + link = link, + currentServer = settings.serverUrl, + newServer = target, + ) + ) + return + } + doEnroll(link, deviceName) + } + + /** The user confirmed replacing an existing enrollment. */ + fun confirmEnroll(deviceName: String? = android.os.Build.MODEL) { + val pending = state.pendingEnroll ?: return + state = state.copy(pendingEnroll = null) + doEnroll(pending.link, deviceName) + } + + fun cancelEnroll() { + state = state.copy( + pendingEnroll = null, + archiveStatus = "kept the existing enrollment; nothing changed", + ) + } + + private fun doEnroll(link: String, deviceName: String?) { viewModelScope.launch { state = state.copy(archiveStatus = "enrolling …") - state = state.copy(archiveStatus = withContext(Dispatchers.IO) { store.enroll(link, deviceName) }) + val result = withContext(Dispatchers.IO) { store.enroll(link, deviceName) } + // A new server means a new canary zone; the old one would describe somebody else's + // deployment. Cleared rather than kept, and relearned from the next profile fetch. + settings.canaryZone = "" + state = state.copy(archiveStatus = result) } }