diff --git a/echolot-app/app/src/main/AndroidManifest.xml b/echolot-app/app/src/main/AndroidManifest.xml index 130cfdb..87da644 100644 --- a/echolot-app/app/src/main/AndroidManifest.xml +++ b/echolot-app/app/src/main/AndroidManifest.xml @@ -22,7 +22,8 @@ + android:exported="true" + android:launchMode="singleTask"> 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 3bd25cc..04cd61a 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 @@ -43,9 +43,27 @@ class MainActivity : ComponentActivity() { private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { /* proceed regardless */ } + /** + * The intent currently being acted on, so a deep link that arrives while the app is running + * is seen by the screen the user is already looking at. + * + * The activity is singleTask for the same reason. As a standard activity it stacked a second + * instance per link, each with its own ViewModel: the enrolment then happened in a throwaway + * copy, and pressing back returned to the original screen showing none of it. Silent, and + * indistinguishable from the link simply not working. + */ + private val liveIntent = mutableStateOf(null) + + override fun onNewIntent(intent: android.content.Intent) { + super.onNewIntent(intent) + setIntent(intent) + liveIntent.value = intent + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) requestRuntimePermissions() + liveIntent.value = intent setContent { MaterialTheme(colorScheme = darkColorScheme()) { Surface(color = MaterialTheme.colorScheme.background) { @@ -66,7 +84,7 @@ class MainActivity : ComponentActivity() { // Both deep links land here. They are told apart by host, so a sign-in // redirect is never mistaken for an enrolment link — one spends a token, the // other completes an authorization, and confusing them would fail obscurely. - val incoming = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.dataString + val incoming = liveIntent.value?.takeIf { it.action == Intent.ACTION_VIEW }?.dataString val authUri = incoming?.takeIf { it.startsWith("echolot://auth") } val enrollUri = incoming?.takeIf { it.startsWith("echolot://enroll") } androidx.compose.runtime.LaunchedEffect(enrollUri) { @@ -80,21 +98,42 @@ class MainActivity : ComponentActivity() { vm.state.pendingEnroll?.let { pending -> androidx.compose.material3.AlertDialog( onDismissRequest = { vm.cancelEnroll() }, - title = { androidx.compose.material3.Text("Replace this device's server?") }, + title = { + androidx.compose.material3.Text( + if (pending.sameServer) "Enroll again with this server?" + else "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." + // Naming the same URL twice reads as a mistake and buries the + // one consequence that actually applies: the device is issued a + // fresh credential and shows up as a second entry. + if (pending.sameServer) { + "This device is already enrolled with " + + "${pending.currentServer}.\n\n" + + "Enrolling again replaces its credential. The old one " + + "stops working immediately, and the device appears on " + + "the server as a new entry alongside the current one — " + + "which you may want to revoke afterwards.\n\n" + + "Runs already uploaded, and runs stored on this phone, " + + "are not affected." + } else { + "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") + androidx.compose.material3.Text( + if (pending.sameServer) "Enroll again" else "Enroll here" + ) } }, dismissButton = { 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 8375415..be3c873 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 @@ -58,7 +58,10 @@ data class UiState( * 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) +data class PendingEnroll(val link: String, val currentServer: String, val newServer: String) { + /** Re-enrolling with the server already configured, rather than moving to a different one. */ + val sameServer: Boolean get() = currentServer.trimEnd('/') == newServer.trimEnd('/') +} /** * Drives one measurement run: device-tier probes (link snapshot, per-network ICMP) always run;