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.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.material3.MaterialTheme
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.lifecycle.lifecycleScope
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.UiState
import kotlinx.coroutines.launch
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 =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { /* proceed regardless */ }
@@ -40,8 +34,8 @@ class MainActivity : ComponentActivity() {
MaterialTheme {
Surface {
ProberScreen(
state = state,
onRun = ::runProbes,
state = vm.state,
onRun = vm::runProbes,
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() {
if (state.results.isEmpty()) return
val intent = ReportWriter.share(this, state.results)
if (vm.state.results.isEmpty()) return
val intent = ReportWriter.share(this, vm.state.results)
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)
}
}
}