From fac3e5f3c9fad6e2d8908b68a9d0692fcec100e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Wed, 4 Sep 2024 19:24:12 +0100 Subject: [PATCH] Finish descriptor screen --- .../values/strings-common.xml | 7 +- .../kotlin/org/ooni/engine/models/TestType.kt | 2 +- .../org/ooni/probe/data/models/Descriptor.kt | 6 + .../data/repositories/ResultRepository.kt | 7 + .../kotlin/org/ooni/probe/di/Dependencies.kt | 15 ++ .../ooni/probe/domain/GetTestDescriptors.kt | 24 +- .../org/ooni/probe/domain/RunDescriptors.kt | 6 +- .../probe/ui/dashboard/DashboardScreen.kt | 23 +- .../probe/ui/dashboard/DashboardViewModel.kt | 12 +- .../probe/ui/dashboard/TestDescriptorItem.kt | 9 +- .../probe/ui/descriptor/DescriptorScreen.kt | 228 ++++++++++++++++++ .../ui/descriptor/DescriptorViewModel.kt | 161 +++++++++++++ .../ooni/probe/ui/navigation/Navigation.kt | 17 ++ .../org/ooni/probe/ui/navigation/Screen.kt | 9 + .../probe/ui/settings/about/AboutScreen.kt | 3 +- .../ooni/probe/ui/shared/MarkdownViewer.kt | 34 +-- .../sqldelight/org/ooni/probe/data/Result.sq | 6 + .../probe/ui/dashboard/DashboardScreenTest.kt | 2 +- 18 files changed, 533 insertions(+), 38 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorViewModel.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index d229aaca..20bfd2c3 100644 --- a/composeApp/src/commonMain/composeResources/values/strings-common.xml +++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml @@ -1,6 +1,9 @@ Dashboard - Last test: %1$s + Last test: + Estimated: + N/A + Running: Estimated time left: Stopping test… @@ -46,6 +49,7 @@ Interested in running OONI Probe tests during emergent censorship events? Enable notifications to receive a message when we hear of internet censorship near you. Test options + Long running test Run tests automatically Number of automated tests: %1$s. @@ -172,6 +176,7 @@ Link Loading Error Link installation cancelled + Back refresh diff --git a/composeApp/src/commonMain/kotlin/org/ooni/engine/models/TestType.kt b/composeApp/src/commonMain/kotlin/org/ooni/engine/models/TestType.kt index 5b5dfdd7..949590b9 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/engine/models/TestType.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/engine/models/TestType.kt @@ -134,7 +134,7 @@ sealed class TestType { override val iconRes: DrawableResource = Res.drawable.test_websites override val url: String = "https://ooni.org/nettest/web-connectivity" - override fun runtime(inputs: List?) = 5.seconds * (inputs?.size ?: 1) + override fun runtime(inputs: List?) = 5.seconds * (inputs?.ifEmpty { null }?.size ?: 30) } data object Whatsapp : TestType() { diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/Descriptor.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/Descriptor.kt index b7feb7ac..76bf8bde 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/Descriptor.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/Descriptor.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color import kotlinx.datetime.LocalDateTime import org.jetbrains.compose.resources.DrawableResource import org.ooni.probe.shared.now +import kotlin.time.Duration.Companion.seconds data class Descriptor( val name: String, @@ -35,4 +36,9 @@ data class Descriptor( } val allTests get() = netTests + longRunningTests + + val estimatedDuration + get() = allTests + .sumOf { it.test.runtime(it.inputs).inWholeSeconds } + .seconds } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/ResultRepository.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/ResultRepository.kt index e770699e..ebe010d9 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/ResultRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/ResultRepository.kt @@ -52,6 +52,13 @@ class ResultRepository( .mapToOneOrNull(backgroundDispatcher) .map { it?.toModel() } + fun getLatestByDescriptor(descriptorKey: String): Flow = + database.resultQueries + .selectLatestByDescriptor(descriptorKey) + .asFlow() + .mapToOneOrNull(backgroundDispatcher) + .map { it?.toModel() } + suspend fun createOrUpdate(model: ResultModel): ResultModel.Id = withContext(backgroundDispatcher) { database.transactionWithResult { 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 bc911b0a..fece02e9 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -60,6 +60,7 @@ import org.ooni.probe.domain.UploadMissingMeasurements import org.ooni.probe.shared.PlatformInfo import org.ooni.probe.ui.dashboard.DashboardViewModel import org.ooni.probe.ui.descriptor.AddDescriptorViewModel +import org.ooni.probe.ui.descriptor.DescriptorViewModel import org.ooni.probe.ui.result.ResultViewModel import org.ooni.probe.ui.results.ResultsViewModel import org.ooni.probe.ui.run.RunViewModel @@ -257,15 +258,29 @@ class Dependencies( goToResults: () -> Unit, goToRunningTest: () -> Unit, goToRunTests: () -> Unit, + goToDescriptor: (String) -> Unit, ) = DashboardViewModel( goToResults = goToResults, goToRunningTest = goToRunningTest, goToRunTests = goToRunTests, + goToDescriptor = goToDescriptor, getTestDescriptors = getTestDescriptors::invoke, observeTestRunState = testStateManager.observeState(), observeTestRunErrors = testStateManager.observeError(), ) + fun descriptorViewModel( + descriptorKey: String, + onBack: () -> Unit, + ) = DescriptorViewModel( + descriptorKey = descriptorKey, + onBack = onBack, + getTestDescriptors = getTestDescriptors::invoke, + getDescriptorLastResult = resultRepository::getLatestByDescriptor, + preferenceRepository = preferenceRepository, + launchUrl = { launchUrl(it, null) }, + ) + fun proxyViewModel(onBack: () -> Unit) = ProxyViewModel(onBack, preferenceRepository) fun resultsViewModel( diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetTestDescriptors.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetTestDescriptors.kt index 0f0232be..d866a838 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetTestDescriptors.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetTestDescriptors.kt @@ -1,9 +1,12 @@ package org.ooni.probe.domain +import androidx.compose.runtime.Composable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.Settings_TestOptions_LongRunningTest import org.jetbrains.compose.resources.stringResource import org.ooni.probe.data.models.DefaultTestDescriptor import org.ooni.probe.data.models.Descriptor @@ -31,7 +34,13 @@ class GetTestDescriptors( name = label, title = { stringResource(title) }, shortDescription = { stringResource(shortDescription) }, - description = { stringResource(description) }, + description = { + if (label == "experimental") { + stringResource(description, experimentalLinks()) + } else { + stringResource(description) + } + }, icon = icon, color = color, animation = animation, @@ -41,4 +50,17 @@ class GetTestDescriptors( longRunningTests = longRunningTests, source = Descriptor.Source.Default(this), ) + + @Composable + private fun experimentalLinks() = + """ + * [STUN Reachability](https://github.com/ooni/spec/blob/master/nettests/ts-025-stun-reachability.md) + * [DNS Check](https://github.com/ooni/spec/blob/master/nettests/ts-028-dnscheck.md) + * [RiseupVPN](https://ooni.org/nettest/riseupvpn/) + * [ECH Check](https://github.com/ooni/spec/blob/master/nettests/ts-039-echcheck.md) + * [Tor Snowflake](https://ooni.org/nettest/tor-snowflake/) (${stringResource(Res.string.Settings_TestOptions_LongRunningTest)}) + * [Vanilla Tor](https://github.com/ooni/spec/blob/master/nettests/ts-016-vanilla-tor.md) (${stringResource( + Res.string.Settings_TestOptions_LongRunningTest, + )}) + """.trimIndent() } 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 a514d27c..ef1c1a52 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunDescriptors.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunDescriptors.kt @@ -21,7 +21,6 @@ import org.ooni.probe.data.models.TestRunState import org.ooni.probe.data.models.UrlModel import org.ooni.probe.shared.now import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds class RunDescriptors( private val getTestDescriptorsBySpec: suspend (RunSpecification) -> List, @@ -115,10 +114,7 @@ class RunDescriptors( private suspend fun List.getEstimatedRuntime(): List { val maxRuntime = getEnginePreferences().maxRuntime return map { descriptor -> - descriptor.netTests - .sumOf { it.test.runtime(it.inputs).inWholeSeconds } - .seconds - .coerceAtMost(maxRuntime ?: Duration.INFINITE) + descriptor.estimatedDuration.coerceAtMost(maxRuntime ?: Duration.INFINITE) } } 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 93ed030f..32f028eb 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 @@ -3,6 +3,7 @@ package org.ooni.probe.ui.dashboard import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues @@ -69,16 +70,22 @@ fun DashboardScreen( LazyColumn( modifier = Modifier.padding(top = 24.dp), + contentPadding = PaddingValues(bottom = 16.dp), ) { - val allSectionsHaveValues = state.tests.entries.all { it.value.any() } - state.tests.forEach { (type, tests) -> - if (allSectionsHaveValues && tests.isNotEmpty()) { + val allSectionsHaveValues = state.descriptors.entries.all { it.value.any() } + state.descriptors.forEach { (type, items) -> + if (allSectionsHaveValues && items.isNotEmpty()) { item(type) { TestDescriptorSection(type) } } - items(tests, key = { it.key }) { test -> - TestDescriptorItem(test) + items(items, key = { it.key }) { descriptor -> + TestDescriptorItem( + descriptor = descriptor, + onClick = { + onEvent(DashboardViewModel.Event.DescriptorClicked(descriptor)) + }, + ) } } } @@ -113,10 +120,8 @@ private fun TestRunStateSection( } state.lastTestAt?.let { lastTestAt -> Text( - text = stringResource( - Res.string.Dashboard_Overview_LatestTest, - lastTestAt.relativeDateTime(), - ), + text = stringResource(Res.string.Dashboard_Overview_LatestTest) + + " " + lastTestAt.relativeDateTime(), style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 4.dp), ) 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 d7e152bd..9bb24b27 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 @@ -19,6 +19,7 @@ class DashboardViewModel( goToResults: () -> Unit, goToRunningTest: () -> Unit, goToRunTests: () -> Unit, + goToDescriptor: (String) -> Unit, getTestDescriptors: () -> Flow>, observeTestRunState: Flow, observeTestRunErrors: Flow, @@ -31,7 +32,7 @@ class DashboardViewModel( init { getTestDescriptors() .onEach { tests -> - _state.update { it.copy(tests = tests.groupByType()) } + _state.update { it.copy(descriptors = tests.groupByType()) } } .launchIn(viewModelScope) @@ -68,6 +69,11 @@ class DashboardViewModel( _state.update { it.copy(testRunErrors = it.testRunErrors - event.error) } } .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { goToDescriptor(it.descriptor.key) } + .launchIn(viewModelScope) } fun onEvent(event: Event) { @@ -81,7 +87,7 @@ class DashboardViewModel( ) data class State( - val tests: Map> = emptyMap(), + val descriptors: Map> = emptyMap(), val testRunState: TestRunState = TestRunState.Idle(), val testRunErrors: List = emptyList(), ) @@ -94,5 +100,7 @@ class DashboardViewModel( data object SeeResultsClick : Event data class ErrorDisplayed(val error: TestRunError) : Event + + data class DescriptorClicked(val descriptor: Descriptor) : Event } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/TestDescriptorItem.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/TestDescriptorItem.kt index 4072474b..26a54028 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/TestDescriptorItem.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/TestDescriptorItem.kt @@ -1,5 +1,6 @@ package org.ooni.probe.ui.dashboard +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -17,10 +18,14 @@ import org.jetbrains.compose.resources.painterResource import org.ooni.probe.data.models.Descriptor @Composable -fun TestDescriptorItem(descriptor: Descriptor) { +fun TestDescriptorItem( + descriptor: Descriptor, + onClick: () -> Unit, +) { Card( Modifier - .padding(horizontal = 16.dp, vertical = 4.dp), + .padding(horizontal = 16.dp, vertical = 4.dp) + .clickable { onClick() }, ) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorScreen.kt new file mode 100644 index 00000000..98db7cca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorScreen.kt @@ -0,0 +1,228 @@ +package org.ooni.probe.ui.descriptor + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TriStateCheckbox +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import ooniprobe.composeapp.generated.resources.AddDescriptor_AutoRun +import ooniprobe.composeapp.generated.resources.AddDescriptor_Settings +import ooniprobe.composeapp.generated.resources.Dashboard_Overview_Estimated +import ooniprobe.composeapp.generated.resources.Dashboard_Overview_LastRun_Never +import ooniprobe.composeapp.generated.resources.Dashboard_Overview_LatestTest +import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.back +import ooniprobe.composeapp.generated.resources.ooni_empty_state +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.ooni.engine.models.TestType +import org.ooni.probe.config.OrganizationConfig +import org.ooni.probe.config.TestDisplayMode +import org.ooni.probe.data.models.Descriptor +import org.ooni.probe.data.models.NetTest +import org.ooni.probe.ui.shared.MarkdownViewer +import org.ooni.probe.ui.shared.SelectableItem +import org.ooni.probe.ui.shared.relativeDateTime +import org.ooni.probe.ui.shared.shortFormat + +@Composable +fun DescriptorScreen( + state: DescriptorViewModel.State, + onEvent: (DescriptorViewModel.Event) -> Unit, +) { + val descriptor = state.descriptor ?: return + + Column { + val descriptorColor = descriptor.color ?: MaterialTheme.colorScheme.primary + TopAppBar( + title = { + Text(descriptor.title.invoke()) + }, + navigationIcon = { + IconButton(onClick = { onEvent(DescriptorViewModel.Event.BackClicked) }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back), + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + titleContentColor = descriptorColor, + navigationIconContentColor = descriptorColor, + actionIconContentColor = descriptorColor, + ), + ) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(WindowInsets.navigationBars.asPaddingValues()) + .padding(bottom = 32.dp), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + .padding(8.dp), + ) { + descriptor.icon?.let { icon -> + Icon( + painterResource(icon), + contentDescription = null, + tint = descriptorColor, + modifier = Modifier.size(64.dp), + ) + } + + Row { + Text(stringResource(Res.string.Dashboard_Overview_Estimated)) + + descriptor.dataUsage()?.let { dataUsage -> + Text( + text = dataUsage, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp), + ) + } + + state.estimatedTime?.let { time -> + Text( + text = "~ ${time.shortFormat()}", + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + + Row { + Text(stringResource(Res.string.Dashboard_Overview_LatestTest)) + + Text( + text = state.lastResult?.startTime?.relativeDateTime() + ?: stringResource(Res.string.Dashboard_Overview_LastRun_Never), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + + MarkdownViewer( + markdown = descriptor.description().orEmpty(), + onUrlClicked = { onEvent(DescriptorViewModel.Event.UrlClicked(it)) }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + ) + + Text( + stringResource(Res.string.AddDescriptor_Settings), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp) + .clickable { onEvent(DescriptorViewModel.Event.AllChecked) }, + ) { + TriStateCheckbox( + state = state.allState, + onClick = { onEvent(DescriptorViewModel.Event.AllChecked) }, + ) + Text( + stringResource(Res.string.AddDescriptor_AutoRun), + modifier = Modifier.padding(start = 16.dp), + ) + } + + when (OrganizationConfig.testDisplayMode) { + TestDisplayMode.Regular -> TestItems(descriptor, state.tests, onEvent) + TestDisplayMode.WebsitesOnly -> WebsiteItems(state.tests) + } + } + } +} + +@Composable +private fun TestItems( + descriptor: Descriptor, + tests: List>, + onEvent: (DescriptorViewModel.Event) -> Unit, +) { + tests.forEach { netTestItem -> + val test = netTestItem.item + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + onEvent( + DescriptorViewModel.Event.TestChecked(test, !netTestItem.isSelected), + ) + } + .padding(start = 32.dp, top = 4.dp), + ) { + Checkbox( + checked = netTestItem.isSelected, + onCheckedChange = { + onEvent(DescriptorViewModel.Event.TestChecked(test, it)) + }, + ) + Icon( + painterResource( + test.test.iconRes ?: descriptor.icon ?: Res.drawable.ooni_empty_state, + ), + contentDescription = null, + modifier = Modifier + .padding(start = 16.dp, end = 8.dp) + .size(24.dp), + ) + Text( + if (test.test is TestType.Experimental) { + test.test.name + } else { + stringResource(test.test.labelRes) + }, + ) + } + } +} + +@Composable +private fun WebsiteItems(tests: List>) { + val websites = tests + .map { it.item } + .filter { it.test is TestType.WebConnectivity } + .flatMap { it.inputs.orEmpty() } + + websites.forEach { website -> + Text( + website, + Modifier.padding(start = 48.dp, top = 4.dp), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorViewModel.kt new file mode 100644 index 00000000..38849d1d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorViewModel.kt @@ -0,0 +1,161 @@ +package org.ooni.probe.ui.descriptor + +import androidx.compose.ui.state.ToggleableState +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +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.SettingsKey +import org.ooni.probe.data.repositories.PreferenceRepository +import org.ooni.probe.ui.shared.SelectableItem +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class DescriptorViewModel( + private val descriptorKey: String, + onBack: () -> Unit, + private val getTestDescriptors: () -> Flow>, + private val getDescriptorLastResult: (String) -> Flow, + private val preferenceRepository: PreferenceRepository, + private val launchUrl: (String) -> Unit, +) : ViewModel() { + private val events = MutableSharedFlow(extraBufferCapacity = 1) + + private val _state = MutableStateFlow(State()) + val state = _state.asStateFlow() + + init { + getDescriptor() + .onEach { if (it == null) onBack() } + .filterNotNull() + .flatMapLatest { descriptor -> + + combine( + preferenceRepository.areNetTestsEnabled( + list = descriptor.allTests.map { descriptor to it }, + isAutoRun = true, + ), + getMaxRuntime(), + ) { preferences, maxRuntime -> + _state.update { + it.copy( + descriptor = descriptor, + estimatedTime = descriptor.estimatedDuration + .coerceAtMost(maxRuntime ?: Duration.INFINITE), + tests = descriptor.allTests.map { test -> + SelectableItem( + item = test, + isSelected = preferences[descriptor to test] == true, + ) + }, + ) + } + } + } + .launchIn(viewModelScope) + + getDescriptorLastResult(descriptorKey) + .onEach { lastResult -> + _state.update { + it.copy(lastResult = lastResult) + } + } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { onBack() } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { launchUrl(it.url) } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { + val descriptor = state.value.descriptor ?: return@onEach + val allTestsSelected = state.value.tests.all { it.isSelected } + preferenceRepository.setAreNetTestsEnabled( + list = descriptor.allTests.map { descriptor to it }, + isAutoRun = true, + isEnabled = !allTestsSelected, + ) + } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { + val descriptor = state.value.descriptor ?: return@onEach + preferenceRepository.setAreNetTestsEnabled( + list = listOf(descriptor to it.test), + isAutoRun = true, + isEnabled = it.isChecked, + ) + } + .launchIn(viewModelScope) + } + + fun onEvent(event: Event) { + events.tryEmit(event) + } + + private fun getDescriptor() = + getTestDescriptors() + .map { list -> list.firstOrNull { it.key == descriptorKey } } + + private fun getMaxRuntime(): Flow = + preferenceRepository.allSettings( + listOf( + SettingsKey.MAX_RUNTIME_ENABLED, + SettingsKey.MAX_RUNTIME, + ), + ).map { preferences -> + val enabled = preferences[SettingsKey.MAX_RUNTIME_ENABLED] == true + val value = preferences[SettingsKey.MAX_RUNTIME] as? Int + if (enabled && value != null) { + value.seconds + } else { + null + } + } + + data class State( + val descriptor: Descriptor? = null, + val estimatedTime: Duration? = null, + val tests: List> = emptyList(), + val lastResult: ResultModel? = null, + ) { + val allState + get() = when (tests.count { it.isSelected }) { + 0 -> ToggleableState.Off + tests.size -> ToggleableState.On + else -> ToggleableState.Indeterminate + } + } + + sealed interface Event { + data object BackClicked : Event + + data class UrlClicked(val url: String) : Event + + data object AllChecked : Event + + data class TestChecked(val test: NetTest, val isChecked: Boolean) : Event + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Navigation.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Navigation.kt index b812dbab..1c4981dd 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Navigation.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Navigation.kt @@ -26,6 +26,7 @@ import org.ooni.probe.di.Dependencies import org.ooni.probe.shared.decodeUrlFromBase64 import org.ooni.probe.ui.dashboard.DashboardScreen import org.ooni.probe.ui.descriptor.AddDescriptorScreen +import org.ooni.probe.ui.descriptor.DescriptorScreen import org.ooni.probe.ui.measurement.MeasurementScreen import org.ooni.probe.ui.result.ResultScreen import org.ooni.probe.ui.results.ResultsScreen @@ -53,6 +54,7 @@ fun Navigation( goToResults = { navController.navigateToMainScreen(Screen.Results) }, goToRunningTest = { navController.navigate(Screen.RunningTest.route) }, goToRunTests = { navController.navigate(Screen.RunTests.route) }, + goToDescriptor = { navController.navigate(Screen.Descriptor(it).route) }, ) } val state by viewModel.state.collectAsState() @@ -229,5 +231,20 @@ fun Navigation( val state by viewModel.state.collectAsState() UploadMeasurementsDialog(state, viewModel::onEvent) } + + composable( + route = Screen.Descriptor.NAV_ROUTE, + arguments = Screen.Descriptor.ARGUMENTS, + ) { entry -> + val descriptorKey = entry.arguments?.getString("descriptorKey") ?: return@composable + val viewModel = viewModel { + dependencies.descriptorViewModel( + descriptorKey = descriptorKey, + onBack = { navController.popBackStack() }, + ) + } + val state by viewModel.state.collectAsState() + DescriptorScreen(state, viewModel::onEvent) + } } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Screen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Screen.kt index 25f49d19..8890cad0 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Screen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Screen.kt @@ -65,4 +65,13 @@ sealed class Screen( data object RunningTest : Screen("running") data object UploadMeasurements : Screen("upload") + + data class Descriptor( + val descriptorKey: String, + ) : Screen("descriptors/$descriptorKey") { + companion object { + const val NAV_ROUTE = "descriptors/{descriptorKey}" + val ARGUMENTS = listOf(navArgument("descriptorKey") { type = NavType.StringType }) + } + } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/about/AboutScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/about/AboutScreen.kt index aad6852a..6c4abac4 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/about/AboutScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/about/AboutScreen.kt @@ -45,7 +45,8 @@ fun AboutScreen(onEvent: (AboutViewModel.Event) -> Unit) { ) { MarkdownViewer( markdown = stringResource(Res.string.Settings_About_Content_Paragraph), - ) { url -> onEvent(AboutViewModel.Event.LaunchUrlClicked(url)) } + onUrlClicked = { url -> onEvent(AboutViewModel.Event.LaunchUrlClicked(url)) }, + ) Spacer(modifier = Modifier.height(16.dp)) InfoLinks( diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/MarkdownViewer.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/MarkdownViewer.kt index efd16e9b..1999b03a 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/MarkdownViewer.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/MarkdownViewer.kt @@ -1,6 +1,7 @@ package org.ooni.probe.ui.shared import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import com.multiplatform.webview.request.RequestInterceptor import com.multiplatform.webview.request.WebRequest import com.multiplatform.webview.request.WebRequestInterceptResult @@ -16,6 +17,7 @@ import org.intellij.markdown.parser.MarkdownParser fun MarkdownViewer( markdown: String, onUrlClicked: (String) -> Unit, + modifier: Modifier = Modifier, ) { val flavour = CommonMarkFlavourDescriptor() val html = HtmlGenerator( @@ -24,20 +26,22 @@ fun MarkdownViewer( flavour, ).generateHtml() val webViewState = rememberWebViewStateWithHTMLData( - data = html, + data = """$html""", + ) + val navigator = rememberWebViewNavigator( + requestInterceptor = object : RequestInterceptor { + override fun onInterceptUrlRequest( + request: WebRequest, + navigator: WebViewNavigator, + ): WebRequestInterceptResult { + onUrlClicked(request.url) + return WebRequestInterceptResult.Reject + } + }, + ) + WebView( + state = webViewState, + navigator = navigator, + modifier = modifier, ) - val navigator = - rememberWebViewNavigator( - requestInterceptor = - object : RequestInterceptor { - override fun onInterceptUrlRequest( - request: WebRequest, - navigator: WebViewNavigator, - ): WebRequestInterceptResult { - onUrlClicked(request.url) - return WebRequestInterceptResult.Reject - } - }, - ) - WebView(state = webViewState, navigator = navigator) } diff --git a/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Result.sq b/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Result.sq index d073aeb9..23dc7e1c 100644 --- a/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Result.sq +++ b/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Result.sq @@ -67,3 +67,9 @@ selectLatest: SELECT * FROM Result ORDER BY start_time DESC LIMIT 1; + +selectLatestByDescriptor: +SELECT * FROM Result +WHERE Result.test_group_name = ?1 OR Result.descriptor_runId = ?1 +ORDER BY start_time DESC +LIMIT 1; diff --git a/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/dashboard/DashboardScreenTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/dashboard/DashboardScreenTest.kt index 0824fc7a..02df2988 100644 --- a/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/dashboard/DashboardScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/dashboard/DashboardScreenTest.kt @@ -17,7 +17,7 @@ class DashboardScreenTest { DashboardScreen( state = DashboardViewModel.State( - tests = mapOf(DescriptorType.Installed to listOf(descriptor)), + descriptors = mapOf(DescriptorType.Installed to listOf(descriptor)), ), onEvent = {}, )