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) } }