From 6c29d0c0392edcd732a20cbe79bd1d3403841120 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Thu, 30 Jul 2026 10:55:32 +0200 Subject: [PATCH] prober: survive configuration changes (rotation wiped a finished run) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../java/app/echo_lot/prober/MainActivity.kt | 41 +++------------- .../app/echo_lot/prober/ProberViewModel.kt | 49 +++++++++++++++++++ 2 files changed, 57 insertions(+), 33 deletions(-) create mode 100644 echolot-prober/app/src/main/java/app/echo_lot/prober/ProberViewModel.kt diff --git a/echolot-prober/app/src/main/java/app/echo_lot/prober/MainActivity.kt b/echolot-prober/app/src/main/java/app/echo_lot/prober/MainActivity.kt index 3dbb510..c746a98 100644 --- a/echolot-prober/app/src/main/java/app/echo_lot/prober/MainActivity.kt +++ b/echolot-prober/app/src/main/java/app/echo_lot/prober/MainActivity.kt @@ -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() - 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")) } diff --git a/echolot-prober/app/src/main/java/app/echo_lot/prober/ProberViewModel.kt b/echolot-prober/app/src/main/java/app/echo_lot/prober/ProberViewModel.kt new file mode 100644 index 0000000..46ce1d1 --- /dev/null +++ b/echolot-prober/app/src/main/java/app/echo_lot/prober/ProberViewModel.kt @@ -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() + 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) + } + } +}