From 68c8a24cc92595a50930dbae2fbbbf7e469df86d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Mon, 19 Aug 2024 15:11:59 +0100 Subject: [PATCH] Stop running test Fix stop. Show test run errors. --- .../values/strings-common.xml | 6 + .../kotlin/org/ooni/engine/Engine.kt | 16 +-- .../commonMain/kotlin/org/ooni/probe/App.kt | 37 ++++-- .../ooni/probe/data/models/TestRunError.kt | 5 + .../models/{TestState.kt => TestRunState.kt} | 8 +- .../kotlin/org/ooni/probe/di/Dependencies.kt | 27 +++-- .../org/ooni/probe/domain/RunDescriptors.kt | 56 +++++++-- .../org/ooni/probe/domain/RunNetTest.kt | 10 +- .../ooni/probe/domain/TestRunStateManager.kt | 31 +++++ .../probe/ui/dashboard/DashboardScreen.kt | 111 +++++++++++++----- .../probe/ui/dashboard/DashboardViewModel.kt | 54 ++++++--- 11 files changed, 266 insertions(+), 95 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestRunError.kt rename composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/{TestState.kt => TestRunState.kt} (87%) create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/domain/TestRunStateManager.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index 31202c11..32fc6c04 100644 --- a/composeApp/src/commonMain/composeResources/values/strings-common.xml +++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml @@ -7,6 +7,11 @@ OONI Tests OONI Run Links + Stopping test… + Finishing the currently pending tests, please wait… + + Stop test + Websites Instant Messaging Middleboxes @@ -126,4 +131,5 @@ Intergovernmental organizations including The United Nations Sites that haven\'t been categorized yet + Unable to download URL list. Please try again. diff --git a/composeApp/src/commonMain/kotlin/org/ooni/engine/Engine.kt b/composeApp/src/commonMain/kotlin/org/ooni/engine/Engine.kt index d12878dc..e64313f3 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/engine/Engine.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/engine/Engine.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.isActive import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.ooni.engine.OonimkallBridge.SubmitMeasurementResults @@ -48,20 +49,19 @@ class Engine( try { task = bridge.startTask(settingsSerialized) - while (!task.isDone()) { + while (!task.isDone() && isActive) { val eventJson = task.waitForNextEvent() val taskEventResult = json.decodeFromString(eventJson) taskEventMapper(taskEventResult)?.let { send(it) } } + } catch (e: CancellationException) { + Logger.d("Test cancelled") + throw e } catch (e: Exception) { - task?.interrupt() + Logger.d("Error while running task", e) throw MkException(e) - } - - invokeOnClose { - if (it is CancellationException) { - task.interrupt() - } + } finally { + task?.interrupt() } }.flowOn(backgroundDispatcher) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt index 7c7c8e76..c4d71e9e 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt @@ -3,9 +3,14 @@ package org.ooni.probe import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.navigation.compose.rememberNavController import co.touchlab.kermit.Logger @@ -19,21 +24,25 @@ import org.ooni.probe.ui.theme.AppTheme @Preview fun App(dependencies: Dependencies) { val navController = rememberNavController() + val snackbarHostState = remember { SnackbarHostState() } - AppTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - Scaffold( - bottomBar = { - BottomNavigationBar(navController) - }, + CompositionLocalProvider( + values = arrayOf(LocalSnackbarHostState provides snackbarHostState), + ) { + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, ) { - Navigation( - navController = navController, - dependencies = dependencies, - ) + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { BottomNavigationBar(navController) }, + ) { + Navigation( + navController = navController, + dependencies = dependencies, + ) + } } } } @@ -56,3 +65,5 @@ private fun logAppStart(dependencies: Dependencies) { ) } } + +val LocalSnackbarHostState = compositionLocalOf { null } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestRunError.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestRunError.kt new file mode 100644 index 00000000..df0b415f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestRunError.kt @@ -0,0 +1,5 @@ +package org.ooni.probe.data.models + +sealed interface TestRunError { + data object DownloadUrlsFailed : TestRunError +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestState.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestRunState.kt similarity index 87% rename from composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestState.kt rename to composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestRunState.kt index e21e8d80..ac335717 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestState.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestRunState.kt @@ -4,8 +4,8 @@ import org.ooni.engine.models.TestType import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -sealed interface TestState { - data object Idle : TestState +sealed interface TestRunState { + data object Idle : TestRunState data class Running( val descriptorName: String? = null, @@ -14,7 +14,7 @@ sealed interface TestState { val testProgress: Double = 0.0, val testIndex: Int = 0, val log: String? = "", - ) : TestState { + ) : TestRunState { val estimatedTimeLeft: Duration? get() { if (estimatedRuntime == null) return null @@ -26,4 +26,6 @@ sealed interface TestState { return remainingTests + remainingFromCurrentTest } } + + data object Stopping : TestRunState } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt index e43d2c13..ac6fbaba 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -8,9 +8,6 @@ import androidx.datastore.preferences.core.Preferences import app.cash.sqldelight.db.SqlDriver import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.serialization.json.Json import okio.FileSystem import okio.Path.Companion.toPath @@ -27,7 +24,6 @@ import org.ooni.probe.data.disk.WriteFileOkio import org.ooni.probe.data.models.PreferenceCategoryKey import org.ooni.probe.data.models.ResultModel import org.ooni.probe.data.models.SettingsCategoryItem -import org.ooni.probe.data.models.TestState import org.ooni.probe.data.repositories.MeasurementRepository import org.ooni.probe.data.repositories.NetworkRepository import org.ooni.probe.data.repositories.PreferenceRepository @@ -44,6 +40,7 @@ import org.ooni.probe.domain.GetTestDescriptors import org.ooni.probe.domain.GetTestDescriptorsBySpec import org.ooni.probe.domain.RunDescriptors import org.ooni.probe.domain.RunNetTest +import org.ooni.probe.domain.TestRunStateManager import org.ooni.probe.shared.PlatformInfo import org.ooni.probe.ui.dashboard.DashboardViewModel import org.ooni.probe.ui.result.ResultViewModel @@ -70,7 +67,6 @@ class Dependencies( private val json by lazy { buildJson() } private val database by lazy { buildDatabase(databaseDriverFactory) } - private val currentTestState by lazy { MutableStateFlow(TestState.Idle) } private val measurementRepository by lazy { MeasurementRepository(database, backgroundDispatcher) @@ -112,7 +108,12 @@ class Dependencies( createOrIgnoreTestDescriptors = testDescriptorRepository::createOrIgnore, ) } - val downloadUrls by lazy { DownloadUrls(engine::checkIn, urlRepository::createOrUpdateByUrl) } + private val downloadUrls by lazy { + DownloadUrls( + engine::checkIn, + urlRepository::createOrUpdateByUrl, + ) + } private val getBootstrapTestDescriptors by lazy { GetBootstrapTestDescriptors(readAssetFile, json, backgroundDispatcher) } @@ -134,7 +135,7 @@ class Dependencies( RunNetTest( startTest = engine::startTask, storeResult = resultRepository::createOrUpdate, - setCurrentTestState = currentTestState::update, + setCurrentTestState = testStateManager::updateState, getUrlByUrl = urlRepository::getByUrl, storeMeasurement = measurementRepository::createOrUpdate, storeNetwork = networkRepository::createIfNew, @@ -149,12 +150,16 @@ class Dependencies( getTestDescriptorsBySpec = getTestDescriptorsBySpec::invoke, downloadUrls = downloadUrls::invoke, storeResult = resultRepository::createOrUpdate, - getCurrentTestState = currentTestState.asStateFlow(), - setCurrentTestState = currentTestState::update, + getCurrentTestRunState = testStateManager.observeState(), + setCurrentTestState = testStateManager::updateState, + observeCancelTestRun = testStateManager.observeTestRunCancels(), + reportTestRunError = testStateManager::reportError, runNetTest = { runNetTest(it)() }, ) } + private val testStateManager by lazy { TestRunStateManager() } + // ViewModels val dashboardViewModel @@ -162,7 +167,9 @@ class Dependencies( DashboardViewModel( getTestDescriptors = getTestDescriptors::invoke, runDescriptors = runDescriptors::invoke, - getCurrentTestState = currentTestState::asStateFlow, + cancelTestRun = testStateManager::cancelTestRun, + observeTestRunState = testStateManager.observeState(), + observeTestRunErrors = testStateManager.observeError(), ) fun resultsViewModel(goToResult: (ResultModel.Id) -> Unit) = ResultsViewModel(goToResult, getResults::invoke) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunDescriptors.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunDescriptors.kt index 85770fde..c3a51fc8 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunDescriptors.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunDescriptors.kt @@ -1,8 +1,11 @@ package org.ooni.probe.domain import co.touchlab.kermit.Logger +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take import org.ooni.engine.Engine.MkException import org.ooni.engine.models.Result import org.ooni.engine.models.TaskOrigin @@ -11,7 +14,8 @@ import org.ooni.probe.data.models.Descriptor import org.ooni.probe.data.models.NetTest import org.ooni.probe.data.models.ResultModel import org.ooni.probe.data.models.RunSpecification -import org.ooni.probe.data.models.TestState +import org.ooni.probe.data.models.TestRunError +import org.ooni.probe.data.models.TestRunState import org.ooni.probe.data.models.UrlModel import kotlin.time.Duration.Companion.seconds @@ -19,27 +23,59 @@ class RunDescriptors( private val getTestDescriptorsBySpec: suspend (RunSpecification) -> List, private val downloadUrls: suspend (TaskOrigin) -> Result, MkException>, private val storeResult: suspend (ResultModel) -> ResultModel.Id, - private val getCurrentTestState: Flow, - private val setCurrentTestState: ((TestState) -> TestState) -> Unit, + private val getCurrentTestRunState: Flow, + private val setCurrentTestState: ((TestRunState) -> TestRunState) -> Unit, + private val observeCancelTestRun: Flow, + private val reportTestRunError: (TestRunError) -> Unit, private val runNetTest: suspend (RunNetTest.Specification) -> Unit, ) { suspend operator fun invoke(spec: RunSpecification) { val descriptors = getTestDescriptorsBySpec(spec) val descriptorsWithFinalInputs = descriptors.prepareInputs(spec.taskOrigin) - if (getCurrentTestState.first() is TestState.Running) { + if (getCurrentTestRunState.first() is TestRunState.Running) { Logger.i("Tests are already running, so we won't run other tests") return } setCurrentTestState { - TestState.Running(estimatedRuntime = descriptorsWithFinalInputs.estimatedRuntime) + TestRunState.Running(estimatedRuntime = descriptorsWithFinalInputs.estimatedRuntime) } - descriptorsWithFinalInputs.forEach { descriptor -> - runDescriptor(descriptor, spec.taskOrigin, spec.isRerun) - } + runDescriptorsCancellable(descriptorsWithFinalInputs, spec) + + setCurrentTestState { TestRunState.Idle } + } - setCurrentTestState { TestState.Idle } + private suspend fun runDescriptorsCancellable( + descriptors: List, + spec: RunSpecification, + ) { + coroutineScope { + val runJob = async { + // Actually running the descriptors + descriptors.forEach { descriptor -> + runDescriptor(descriptor, spec.taskOrigin, spec.isRerun) + } + } + // Observe if a cancel request has been made + val cancelJob = async { + observeCancelTestRun + .take(1) + .collect { + setCurrentTestState { TestRunState.Stopping } + runJob.cancel() + } + } + + try { + runJob.await() + } catch (e: Exception) { + // Exceptions were logged in the Engine + } + if (cancelJob.isActive) { + cancelJob.cancel() + } + } } private suspend fun List.prepareInputs(taskOrigin: TaskOrigin) = @@ -63,7 +99,7 @@ class RunDescriptors( ?: emptyList() if (urls.isEmpty()) { - // TODO: Add error to state + reportTestRunError(TestRunError.DownloadUrlsFailed) } return urls diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunNetTest.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunNetTest.kt index 95dc8c43..3368031b 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunNetTest.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunNetTest.kt @@ -16,7 +16,7 @@ import org.ooni.probe.data.models.MeasurementModel import org.ooni.probe.data.models.NetTest import org.ooni.probe.data.models.NetworkModel import org.ooni.probe.data.models.ResultModel -import org.ooni.probe.data.models.TestState +import org.ooni.probe.data.models.TestRunState import org.ooni.probe.data.models.UrlModel import org.ooni.probe.shared.toLocalDateTime import kotlin.time.Duration @@ -27,7 +27,7 @@ class RunNetTest( private val storeMeasurement: suspend (MeasurementModel) -> MeasurementModel.Id, private val storeNetwork: suspend (NetworkModel) -> NetworkModel.Id, private val storeResult: suspend (ResultModel) -> ResultModel.Id, - private val setCurrentTestState: ((TestState) -> TestState) -> Unit, + private val setCurrentTestState: ((TestRunState) -> TestRunState) -> Unit, private val writeFile: WriteFile, private val deleteFile: DeleteFile, private val json: Json, @@ -51,7 +51,7 @@ class RunNetTest( suspend operator fun invoke() { setCurrentTestState { - if (it !is TestState.Running) return@setCurrentTestState it + if (it !is TestRunState.Running) return@setCurrentTestState it it.copy( testType = spec.netTest.test, testProgress = spec.testIndex * progressStep, @@ -116,7 +116,7 @@ class RunNetTest( ) setCurrentTestState { - if (it !is TestState.Running) return@setCurrentTestState it + if (it !is TestRunState.Running) return@setCurrentTestState it it.copy(log = event.message) } @@ -125,7 +125,7 @@ class RunNetTest( is TaskEvent.Progress -> { setCurrentTestState { - if (it !is TestState.Running) return@setCurrentTestState it + if (it !is TestRunState.Running) return@setCurrentTestState it it.copy( testProgress = (spec.testIndex + event.progress) * progressStep, log = event.message, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/TestRunStateManager.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/TestRunStateManager.kt new file mode 100644 index 00000000..5cffa98c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/TestRunStateManager.kt @@ -0,0 +1,31 @@ +package org.ooni.probe.domain + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.ooni.probe.data.models.TestRunError +import org.ooni.probe.data.models.TestRunState + +class TestRunStateManager { + private val state = MutableStateFlow(TestRunState.Idle) + private val cancels = MutableSharedFlow(extraBufferCapacity = 1) + private val errors = MutableSharedFlow(extraBufferCapacity = 1) + + fun observeState() = state.asStateFlow() + + fun observeTestRunCancels() = cancels.asSharedFlow() + + fun observeError() = errors.asSharedFlow() + + fun updateState(update: (TestRunState) -> TestRunState) = state.update(update) + + fun cancelTestRun() { + cancels.tryEmit(Unit) + } + + fun reportError(error: TestRunError) { + errors.tryEmit(error) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardScreen.kt index 6d7f076a..0e5bad24 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardScreen.kt @@ -9,13 +9,19 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.unit.dp +import ooniprobe.composeapp.generated.resources.Dashboard_Running_Stopping_Notice +import ooniprobe.composeapp.generated.resources.Dashboard_Running_Stopping_Title +import ooniprobe.composeapp.generated.resources.Modal_Error_CantDownloadURLs +import ooniprobe.composeapp.generated.resources.Notification_StopTest import ooniprobe.composeapp.generated.resources.Res import ooniprobe.composeapp.generated.resources.app_name import ooniprobe.composeapp.generated.resources.logo @@ -23,7 +29,9 @@ import ooniprobe.composeapp.generated.resources.run_tests import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview -import org.ooni.probe.data.models.TestState +import org.ooni.probe.LocalSnackbarHostState +import org.ooni.probe.data.models.TestRunError +import org.ooni.probe.data.models.TestRunState import org.ooni.probe.ui.theme.AppTheme @Composable @@ -45,38 +53,58 @@ fun DashboardScreen( colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), ) - if (state.testState is TestState.Running) { - Text( - text = state.testState.testType?.let { stringResource(it.labelRes) }.orEmpty(), - style = MaterialTheme.typography.titleLarge, - ) - Text( - text = state.testState.log.orEmpty(), - ) - state.testState.testProgress.let { progress -> - if (progress == 0.0) { - LinearProgressIndicator( - modifier = - Modifier.fillMaxWidth() - .padding(16.dp), - ) - } else { - LinearProgressIndicator( - progress = { progress.toFloat() }, - modifier = - Modifier.fillMaxWidth() - .padding(16.dp), - ) + when (state.testRunState) { + TestRunState.Idle -> { + Button( + onClick = { onEvent(DashboardViewModel.Event.RunTestsClick) }, + ) { + Text(stringResource(Res.string.run_tests)) } } - Text( - text = state.testState.estimatedTimeLeft?.toString().orEmpty(), - ) - } else { - Button( - onClick = { onEvent(DashboardViewModel.Event.StartClick) }, - ) { - Text(stringResource(Res.string.run_tests)) + + is TestRunState.Running -> { + Text( + text = state.testRunState.testType?.let { stringResource(it.labelRes) } + .orEmpty(), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = state.testRunState.log.orEmpty(), + ) + state.testRunState.testProgress.let { progress -> + if (progress == 0.0) { + LinearProgressIndicator( + modifier = + Modifier.fillMaxWidth() + .padding(16.dp), + ) + } else { + LinearProgressIndicator( + progress = { progress.toFloat() }, + modifier = + Modifier.fillMaxWidth() + .padding(16.dp), + ) + } + } + Text( + text = state.testRunState.estimatedTimeLeft?.toString().orEmpty(), + ) + Button( + onClick = { onEvent(DashboardViewModel.Event.StopTestsClick) }, + ) { + Text(stringResource(Res.string.Notification_StopTest)) + } + } + + TestRunState.Stopping -> { + Text( + text = stringResource(Res.string.Dashboard_Running_Stopping_Title), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = stringResource(Res.string.Dashboard_Running_Stopping_Notice), + ) } } @@ -94,6 +122,27 @@ fun DashboardScreen( } } } + + ErrorMessages(state, onEvent) +} + +@Composable +private fun ErrorMessages( + state: DashboardViewModel.State, + onEvent: (DashboardViewModel.Event) -> Unit, +) { + val snackbarHostState = LocalSnackbarHostState.current ?: return + val errorMessage = when (state.testRunErrors.firstOrNull()) { + TestRunError.DownloadUrlsFailed -> stringResource(Res.string.Modal_Error_CantDownloadURLs) + null -> "" + } + LaunchedEffect(state.testRunErrors) { + val error = state.testRunErrors.firstOrNull() ?: return@LaunchedEffect + val result = snackbarHostState.showSnackbar(errorMessage) + if (result == SnackbarResult.Dismissed) { + onEvent(DashboardViewModel.Event.ErrorDisplayed(error)) + } + } } @Preview diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardViewModel.kt index c43c273a..4501178d 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardViewModel.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -14,12 +15,15 @@ import kotlinx.coroutines.launch import org.ooni.engine.models.TaskOrigin import org.ooni.probe.data.models.Descriptor import org.ooni.probe.data.models.RunSpecification -import org.ooni.probe.data.models.TestState +import org.ooni.probe.data.models.TestRunError +import org.ooni.probe.data.models.TestRunState class DashboardViewModel( getTestDescriptors: () -> Flow>, runDescriptors: suspend (RunSpecification) -> Unit, - getCurrentTestState: () -> Flow, + cancelTestRun: () -> Unit, + observeTestRunState: Flow, + observeTestRunErrors: Flow, ) : ViewModel() { private val events = MutableSharedFlow(extraBufferCapacity = 1) @@ -33,24 +37,39 @@ class DashboardViewModel( } .launchIn(viewModelScope) - getCurrentTestState() + observeTestRunState .onEach { testState -> - _state.update { it.copy(testState = testState) } + _state.update { it.copy(testRunState = testState) } + } + .launchIn(viewModelScope) + + observeTestRunErrors + .onEach { error -> + _state.update { it.copy(testRunErrors = it.testRunErrors + error) } } .launchIn(viewModelScope) events - .onEach { event -> - when (event) { - Event.StartClick -> { - coroutineScope { - launch { - runDescriptors(buildRunSpecification()) - } - } + .filterIsInstance() + .onEach { + coroutineScope { + launch { + runDescriptors(buildRunSpecification()) } } - }.launchIn(viewModelScope) + } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { cancelTestRun() } + .launchIn(viewModelScope) + events + .filterIsInstance() + .onEach { event -> + _state.update { it.copy(testRunErrors = it.testRunErrors - event.error) } + } + .launchIn(viewModelScope) } fun onEvent(event: Event) { @@ -92,10 +111,15 @@ class DashboardViewModel( data class State( val tests: Map> = emptyMap(), - val testState: TestState = TestState.Idle, + val testRunState: TestRunState = TestRunState.Idle, + val testRunErrors: List = emptyList(), ) sealed interface Event { - data object StartClick : Event + data object RunTestsClick : Event + + data object StopTestsClick : Event + + data class ErrorDisplayed(val error: TestRunError) : Event } }