prober: survive configuration changes (rotation wiped a finished run)

Found on the OnePlus 15 (CPH2747): rotating after a run emptied the result
list — results lived in a MainActivity field and the run in lifecycleScope,
so a config change dropped collected results and cancelled remaining probes.
Both now live in ProberViewModel (viewModelScope + application context; no
probe needs an Activity). A lost run means a lost export, so this matters
for a collection tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-07-30 10:55:32 +02:00
co-authored by Claude Opus 5
parent d3e35ecead
commit 6c29d0c039
2 changed files with 57 additions and 33 deletions
@@ -11,24 +11,18 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import app.echo_lot.prober.export.ReportWriter import app.echo_lot.prober.export.ReportWriter
import app.echo_lot.prober.probe.ProbeRegistry
import app.echo_lot.prober.probe.ProbeResult
import app.echo_lot.prober.probe.Verdict
import app.echo_lot.prober.ui.ProberScreen import app.echo_lot.prober.ui.ProberScreen
import app.echo_lot.prober.ui.UiState
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var state by mutableStateOf(UiState()) // Run state lives in the ViewModel so rotation neither cancels a running
// probe sequence nor discards collected results.
private val vm: ProberViewModel by viewModels()
private val permissionLauncher = private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { /* proceed regardless */ } registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { /* proceed regardless */ }
@@ -40,8 +34,8 @@ class MainActivity : ComponentActivity() {
MaterialTheme { MaterialTheme {
Surface { Surface {
ProberScreen( ProberScreen(
state = state, state = vm.state,
onRun = ::runProbes, onRun = vm::runProbes,
onShare = ::shareReport, onShare = ::shareReport,
) )
} }
@@ -49,28 +43,9 @@ class MainActivity : ComponentActivity() {
} }
} }
private fun runProbes() {
if (state.running) return
state = state.copy(running = true, results = emptyList(), currentTitle = null)
lifecycleScope.launch {
val acc = mutableListOf<ProbeResult>()
for (probe in ProbeRegistry.all) {
state = state.copy(currentTitle = probe.title)
val result = try {
probe.run(this@MainActivity)
} catch (t: Throwable) {
ProbeResult.of(probe, Verdict.ERROR, "Uncaught: ${t.message ?: t.javaClass.simpleName}")
}
acc.add(result)
state = state.copy(results = acc.toList())
}
state = state.copy(running = false, currentTitle = null)
}
}
private fun shareReport() { private fun shareReport() {
if (state.results.isEmpty()) return if (vm.state.results.isEmpty()) return
val intent = ReportWriter.share(this, state.results) val intent = ReportWriter.share(this, vm.state.results)
startActivity(Intent.createChooser(intent, "Export Echolot prober report")) startActivity(Intent.createChooser(intent, "Export Echolot prober report"))
} }
@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.prober
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import app.echo_lot.prober.probe.ProbeRegistry
import app.echo_lot.prober.probe.ProbeResult
import app.echo_lot.prober.probe.Verdict
import app.echo_lot.prober.ui.UiState
import kotlinx.coroutines.launch
/**
* Owns the probe run and its results so both survive configuration changes.
* Rotating mid-run must neither cancel remaining probes (viewModelScope, not
* lifecycleScope) nor drop collected results (ViewModel, not Activity field) —
* a lost run means a lost export.
*
* Probes get the Application context; none of them need an Activity.
*/
class ProberViewModel(app: Application) : AndroidViewModel(app) {
var state by mutableStateOf(UiState())
private set
fun runProbes() {
if (state.running) return
state = state.copy(running = true, results = emptyList(), currentTitle = null)
viewModelScope.launch {
val acc = mutableListOf<ProbeResult>()
for (probe in ProbeRegistry.all) {
state = state.copy(currentTitle = probe.title)
val result = try {
probe.run(getApplication())
} catch (t: Throwable) {
ProbeResult.of(probe, Verdict.ERROR, "Uncaught: ${t.message ?: t.javaClass.simpleName}")
}
acc.add(result)
state = state.copy(results = acc.toList())
}
state = state.copy(running = false, currentTitle = null)
}
}
}