From 6794395d88c0d2a588288da62dba2be0af5dd4ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Tue, 14 Oct 2025 17:41:01 +0100 Subject: [PATCH 01/21] Move tests to separate screen --- .../ooni/probe/uitesting/DescriptorsTest.kt | 13 +- .../values/strings-common.xml | 5 +- .../commonMain/kotlin/org/ooni/probe/App.kt | 3 +- .../kotlin/org/ooni/probe/di/Dependencies.kt | 24 +- .../probe/ui/dashboard/DashboardScreen.kt | 249 ++++-------------- .../probe/ui/dashboard/DashboardViewModel.kt | 148 +---------- .../probe/ui/descriptors/DescriptorsScreen.kt | 208 +++++++++++++++ .../ui/descriptors/DescriptorsViewModel.kt | 175 ++++++++++++ .../ui/descriptors/TestDescriptorItem.kt | 70 +++++ .../ui/descriptors/TestDescriptorLabel.kt | 46 ++++ .../ui/descriptors/TestDescriptorTypeTitle.kt | 20 ++ .../probe/ui/navigation/BottomBarViewModel.kt | 14 + .../ui/navigation/BottomNavigationBar.kt | 9 +- .../ooni/probe/ui/navigation/Navigation.kt | 16 +- .../org/ooni/probe/ui/navigation/Screen.kt | 2 + .../ooni/probe/ui/results/ResultsScreen.kt | 4 +- .../DescriptorsScreenTest.kt} | 12 +- .../macos/libnetworktypefinder.dylib | Bin .../resources/macos/libupdatebridge.dylib | Bin 74328 -> 74328 bytes 19 files changed, 649 insertions(+), 369 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/TestDescriptorItem.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/TestDescriptorLabel.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/TestDescriptorTypeTitle.kt rename composeApp/src/commonTest/kotlin/org/ooni/probe/ui/{dashboard/DashboardScreenTest.kt => descriptors/DescriptorsScreenTest.kt} (78%) mode change 100755 => 100644 composeApp/src/desktopMain/resources/macos/libnetworktypefinder.dylib diff --git a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/DescriptorsTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/DescriptorsTest.kt index ff003a8bd..13232d183 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/DescriptorsTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/DescriptorsTest.kt @@ -25,6 +25,7 @@ import ooniprobe.composeapp.generated.resources.Dashboard_ReviewDescriptor_Butto import ooniprobe.composeapp.generated.resources.Dashboard_Runv2_Overview_UninstallLink import ooniprobe.composeapp.generated.resources.AddDescriptor_InstallForLater import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.Tests_Title import org.jetbrains.compose.resources.getString import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -82,8 +83,9 @@ class DescriptorsTest { Thread.sleep(2000) - wait { onNodeWithTag("Dashboard-List").isDisplayed() } - onNodeWithTag("Dashboard-List") + clickOnText(Res.string.Tests_Title) + wait { onNodeWithTag("Descriptors-List").isDisplayed() } + onNodeWithTag("Descriptors-List") .performScrollToNode(hasText("Android instrumented tests")) onNodeWithText("Testing").assertIsDisplayed() @@ -132,9 +134,10 @@ class DescriptorsTest { setupTestEngine() - wait { onNodeWithTag("Dashboard-List").isDisplayed() } + clickOnText(Res.string.Tests_Title) + wait { onNodeWithTag("Descriptors-List").isDisplayed() } // Pull down to refresh - onNodeWithTag("Dashboard-List").performTouchInput { swipeDown() } + onNodeWithTag("Descriptors-List").performTouchInput { swipeDown() } clickOnText( Res.string.Dashboard_Progress_ReviewLink_Action, @@ -145,7 +148,7 @@ class DescriptorsTest { clickOnText(getString(Res.string.Dashboard_ReviewDescriptor_Button_Last, 1, 1)) - onNodeWithTag("Dashboard-List") + onNodeWithTag("Descriptors-List") .performScrollToNode(hasText("Android instrumented tests")) onNodeWithText("Testing 2").assertIsDisplayed() } diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index 848b3f552..3cd003720 100644 --- a/composeApp/src/commonMain/composeResources/values/strings-common.xml +++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml @@ -52,8 +52,9 @@ The app update has just been downloaded Restart - + + Tests Websites Instant Messaging Performance @@ -75,7 +76,7 @@ Test Results Test Results - Tests + Results Networks Data Usage diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt index 01ff8e68f..25fe2c859 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt @@ -169,4 +169,5 @@ private fun logAppStart(platformInfo: PlatformInfo) { val LocalSnackbarHostState = compositionLocalOf { null } -val MAIN_NAVIGATION_SCREENS = listOf(Screen.Dashboard, Screen.Results, Screen.Settings) +val MAIN_NAVIGATION_SCREENS = + listOf(Screen.Dashboard, Screen.Descriptors, Screen.Results, Screen.Settings) 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 e189ec077..f8a6ad119 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -99,6 +99,7 @@ import org.ooni.probe.ui.descriptor.DescriptorViewModel import org.ooni.probe.ui.descriptor.add.AddDescriptorViewModel import org.ooni.probe.ui.descriptor.review.ReviewUpdatesViewModel import org.ooni.probe.ui.descriptor.websites.DescriptorWebsitesViewModel +import org.ooni.probe.ui.descriptors.DescriptorsViewModel import org.ooni.probe.ui.log.LogViewModel import org.ooni.probe.ui.measurement.MeasurementRawViewModel import org.ooni.probe.ui.measurement.MeasurementViewModel @@ -564,25 +565,33 @@ class Dependencies( goToResults: () -> Unit, goToRunningTest: () -> Unit, goToRunTests: () -> Unit, - goToDescriptor: (String) -> Unit, - goToReviewDescriptorUpdates: (List?) -> Unit, ) = DashboardViewModel( goToOnboarding = goToOnboarding, goToResults = goToResults, goToRunningTest = goToRunningTest, goToRunTests = goToRunTests, - goToDescriptor = goToDescriptor, getFirstRun = getFirstRun::invoke, - goToReviewDescriptorUpdates = goToReviewDescriptorUpdates, - getTestDescriptors = getTestDescriptors::latest, observeRunBackgroundState = runBackgroundStateManager.observeState(), observeTestRunErrors = runBackgroundStateManager.observeErrors(), shouldShowVpnWarning = shouldShowVpnWarning::invoke, + getAutoRunSettings = getAutoRunSettings::invoke, + batteryOptimization = batteryOptimization, + ) + + fun descriptorsViewModel( + goToOnboarding: () -> Unit, + goToResults: () -> Unit, + goToRunningTest: () -> Unit, + goToRunTests: () -> Unit, + goToDescriptor: (String) -> Unit, + goToReviewDescriptorUpdates: (List?) -> Unit, + ) = DescriptorsViewModel( + goToDescriptor = goToDescriptor, + goToReviewDescriptorUpdates = goToReviewDescriptorUpdates, + getTestDescriptors = getTestDescriptors::latest, startDescriptorsUpdates = startDescriptorsUpdate, dismissDescriptorsUpdateNotice = dismissDescriptorReviewNotice::invoke, observeDescriptorUpdateState = descriptorUpdateStateManager::observe, - getAutoRunSettings = getAutoRunSettings::invoke, - batteryOptimization = batteryOptimization, canPullToRefresh = platformInfo.canPullToRefresh, getPreference = preferenceRepository::getValueByKey, setPreference = preferenceRepository::setValueByKey, @@ -796,6 +805,7 @@ class Dependencies( BottomBarViewModel( countAllNotViewedFlow = resultRepository::countAllNotViewedFlow, runBackgroundStateFlow = runBackgroundStateManager::observeState, + observeDescriptorUpdateState = descriptorUpdateStateManager::observe, ) companion object { 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 d2762c304..604a883c5 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 @@ -2,60 +2,41 @@ package org.ooni.probe.ui.dashboard import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults -import androidx.compose.material3.pulltorefresh.pullToRefresh -import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect -import ooniprobe.composeapp.generated.resources.Common_Collapse -import ooniprobe.composeapp.generated.resources.Common_Expand -import ooniprobe.composeapp.generated.resources.DescriptorUpdate_CheckUpdates import ooniprobe.composeapp.generated.resources.Modal_DisableVPN_Title import ooniprobe.composeapp.generated.resources.Res import ooniprobe.composeapp.generated.resources.app_name import ooniprobe.composeapp.generated.resources.dashboard_arc -import ooniprobe.composeapp.generated.resources.ic_keyboard_arrow_down -import ooniprobe.composeapp.generated.resources.ic_keyboard_arrow_up import ooniprobe.composeapp.generated.resources.ic_warning import ooniprobe.composeapp.generated.resources.logo_probe 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.DescriptorType -import org.ooni.probe.data.models.DescriptorUpdateOperationState import org.ooni.probe.ui.shared.IgnoreBatteryOptimizationDialog import org.ooni.probe.ui.shared.TestRunErrorMessages -import org.ooni.probe.ui.shared.UpdateProgressStatus import org.ooni.probe.ui.shared.VerticalScrollbar import org.ooni.probe.ui.shared.isHeightCompact import org.ooni.probe.ui.theme.AppTheme @@ -65,122 +46,64 @@ fun DashboardScreen( state: DashboardViewModel.State, onEvent: (DashboardViewModel.Event) -> Unit, ) { - val pullRefreshState = rememberPullToRefreshState() - Box( - Modifier - .pullToRefresh( - isRefreshing = state.isRefreshing, - onRefresh = { onEvent(DashboardViewModel.Event.FetchUpdatedDescriptors) }, - state = pullRefreshState, - enabled = state.isRefreshEnabled && state.canPullToRefresh, - ).background(MaterialTheme.colorScheme.background) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .background(MaterialTheme.colorScheme.background) .fillMaxSize(), ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .padding(bottom = if (state.isRefreshing) 48.dp else 0.dp) - .fillMaxWidth(), + // Colorful top background with logo + Box( + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(WindowInsets.statusBars.asPaddingValues()) + .height(if (isHeightCompact()) 64.dp else 112.dp), ) { - // Colorful top background - Box( - Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.primaryContainer) - .padding(WindowInsets.statusBars.asPaddingValues()) - .height(if (isHeightCompact()) 64.dp else 112.dp), + Image( + painterResource(Res.drawable.logo_probe), + contentDescription = stringResource(Res.string.app_name), + modifier = Modifier + .padding(vertical = if (isHeightCompact()) 4.dp else 20.dp) + .align(Alignment.Center) + .height(if (isHeightCompact()) 48.dp else 72.dp), + ) + } + + // Run Section + Box { + Image( + painterResource(Res.drawable.dashboard_arc), + contentDescription = null, + contentScale = ContentScale.FillBounds, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primaryContainer), + modifier = Modifier.fillMaxWidth().height(32.dp), + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), ) { - Image( - painterResource(Res.drawable.logo_probe), - contentDescription = stringResource(Res.string.app_name), - modifier = Modifier - .padding(vertical = if (isHeightCompact()) 4.dp else 20.dp) - .align(Alignment.Center) - .height(if (isHeightCompact()) 48.dp else 72.dp), - ) + RunBackgroundStateSection(state.runBackgroundState, onEvent) } + } - Box { - Image( - painterResource(Res.drawable.dashboard_arc), - contentDescription = null, - contentScale = ContentScale.FillBounds, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primaryContainer), - modifier = Modifier.fillMaxWidth().height(32.dp), - ) - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), - ) { - RunBackgroundStateSection(state.runBackgroundState, onEvent) + Box(Modifier.fillMaxSize()) { + val scrollState = rememberScrollState() + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .verticalScroll(scrollState) + .fillMaxSize(), + ) { + if (state.showVpnWarning) { + VpnWarning() } - } - if (state.showVpnWarning) { - VpnWarning() + // TODO: Rest of the content here } - - Box { - val lazyListState = rememberLazyListState() - LazyColumn( - modifier = Modifier - .padding(top = if (isHeightCompact()) 8.dp else 16.dp) - .testTag("Dashboard-List"), - contentPadding = PaddingValues(bottom = 16.dp), - state = lazyListState, - ) { - val allSectionsHaveValues = state.sections.all { it.descriptors.any() } - state.sections.forEach { (type, descriptors, isCollapsed) -> - if (allSectionsHaveValues && descriptors.isNotEmpty()) { - item(type) { - TestDescriptorSectionTitle( - type = type, - isCollapsed = isCollapsed, - state = state, - onEvent = onEvent, - ) - } - } - if (isCollapsed) return@forEach - items(descriptors, key = { it.key }) { descriptor -> - TestDescriptorItem( - descriptor = descriptor, - onClick = { - onEvent( - DashboardViewModel.Event.DescriptorClicked(descriptor), - ) - }, - onUpdateClick = { - onEvent( - DashboardViewModel.Event.UpdateDescriptorClicked(descriptor), - ) - }, - ) - } - } - } - VerticalScrollbar( - state = lazyListState, - modifier = Modifier.align(Alignment.CenterEnd), - ) - } - } - - if (state.descriptorsUpdateOperationState != DescriptorUpdateOperationState.Idle) { - UpdateProgressStatus( - modifier = Modifier.align(Alignment.BottomCenter), - type = state.descriptorsUpdateOperationState, - onReviewLinkClicked = { onEvent(DashboardViewModel.Event.ReviewUpdatesClicked) }, - onCancelClicked = { onEvent(DashboardViewModel.Event.CancelUpdatesClicked) }, - ) + VerticalScrollbar(state = scrollState, modifier = Modifier.align(Alignment.CenterEnd)) } - - PullToRefreshDefaults.Indicator( - modifier = Modifier.align(Alignment.TopCenter), - isRefreshing = state.isRefreshing, - state = pullRefreshState, - ) } TestRunErrorMessages( @@ -197,56 +120,7 @@ fun DashboardScreen( LifecycleResumeEffect(Unit) { onEvent(DashboardViewModel.Event.Resumed) - onPauseOrDispose { - onEvent(DashboardViewModel.Event.Paused) - } - } -} - -@Composable -private fun TestDescriptorSectionTitle( - type: DescriptorType, - isCollapsed: Boolean, - state: DashboardViewModel.State, - onEvent: (DashboardViewModel.Event) -> Unit, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .clickable { onEvent(DashboardViewModel.Event.ToggleSection(type)) } - .padding(horizontal = 16.dp) - .defaultMinSize(minHeight = 40.dp) - .padding(vertical = 1.dp), - ) { - TestDescriptorTypeTitle(type) - Icon( - painterResource( - if (isCollapsed) { - Res.drawable.ic_keyboard_arrow_down - } else { - Res.drawable.ic_keyboard_arrow_up - }, - ), - contentDescription = stringResource( - if (isCollapsed) { - Res.string.Common_Expand - } else { - Res.string.Common_Collapse - }, - ) + " " + stringResource(type.title), - modifier = Modifier - .padding(horizontal = 8.dp) - .size(16.dp), - ) - Spacer(Modifier.weight(1f)) - if (type == DescriptorType.Installed && !state.canPullToRefresh) { - CheckUpdatesButton( - enabled = !state.isRefreshing, - onEvent = onEvent, - ) - } + onPauseOrDispose { onEvent(DashboardViewModel.Event.Paused) } } } @@ -268,27 +142,6 @@ private fun VpnWarning() { } } -@Composable -private fun CheckUpdatesButton( - enabled: Boolean, - onEvent: (DashboardViewModel.Event) -> Unit, -) { - TextButton( - onClick = { onEvent(DashboardViewModel.Event.FetchUpdatedDescriptors) }, - enabled = enabled, - contentPadding = PaddingValues( - horizontal = 8.dp, - vertical = 4.dp, - ), - modifier = Modifier.defaultMinSize(minHeight = 32.dp), - ) { - Text( - stringResource(Res.string.DescriptorUpdate_CheckUpdates), - style = MaterialTheme.typography.labelMedium, - ) - } -} - @Preview @Composable fun DashboardScreenPreview() { 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 10c997665..9ce6d67b9 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 @@ -6,10 +6,8 @@ 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.emptyFlow import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.merge @@ -18,13 +16,7 @@ import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.update import org.ooni.probe.config.BatteryOptimization import org.ooni.probe.data.models.AutoRunParameters -import org.ooni.probe.data.models.Descriptor -import org.ooni.probe.data.models.DescriptorType -import org.ooni.probe.data.models.DescriptorUpdateOperationState -import org.ooni.probe.data.models.DescriptorsUpdateState -import org.ooni.probe.data.models.InstalledTestDescriptorModel import org.ooni.probe.data.models.RunBackgroundState -import org.ooni.probe.data.models.SettingsKey import org.ooni.probe.data.models.TestRunError import org.ooni.probe.shared.tickerFlow import kotlin.time.Duration.Companion.seconds @@ -34,25 +26,16 @@ class DashboardViewModel( goToResults: () -> Unit, goToRunningTest: () -> Unit, goToRunTests: () -> Unit, - goToDescriptor: (String) -> Unit, getFirstRun: () -> Flow, - goToReviewDescriptorUpdates: (List?) -> Unit, - getTestDescriptors: () -> Flow>, observeRunBackgroundState: Flow, observeTestRunErrors: Flow, shouldShowVpnWarning: suspend () -> Boolean, - observeDescriptorUpdateState: () -> Flow, - startDescriptorsUpdates: suspend (List?) -> Unit, - dismissDescriptorsUpdateNotice: () -> Unit, getAutoRunSettings: () -> Flow, batteryOptimization: BatteryOptimization, - canPullToRefresh: Boolean, - private val getPreference: (SettingsKey) -> Flow, - private val setPreference: suspend (SettingsKey, Any?) -> Unit, ) : ViewModel() { private val events = MutableSharedFlow(extraBufferCapacity = 1) - private val _state = MutableStateFlow(State(canPullToRefresh = canPullToRefresh)) + private val _state = MutableStateFlow(State()) val state = _state.asStateFlow() init { @@ -72,24 +55,6 @@ class DashboardViewModel( } }.launchIn(viewModelScope) - observeDescriptorUpdateState() - .onEach { updates -> - _state.update { - it.copy( - availableUpdates = updates.availableUpdates.toList(), - descriptorsUpdateOperationState = updates.operationState, - ) - } - }.launchIn(viewModelScope) - - combine( - getTestDescriptors(), - getPreference(SettingsKey.DESCRIPTOR_SECTIONS_COLLAPSED), - ) { tests, collapsedSectionsPreference -> - val collapsedSections = collapsedSectionsPreference.toCollapsedSections() - _state.update { it.copy(sections = tests.groupByType(collapsedSections)) } - }.launchIn(viewModelScope) - observeRunBackgroundState .onEach { testState -> _state.update { it.copy(runBackgroundState = testState) } @@ -121,11 +86,6 @@ class DashboardViewModel( _state.update { it.copy(testRunErrors = it.testRunErrors - event.error) } }.launchIn(viewModelScope) - events - .filterIsInstance() - .onEach { event -> goToDescriptor(event.descriptor.key) } - .launchIn(viewModelScope) - merge( events.filterIsInstance(), events.filterIsInstance(), @@ -139,40 +99,6 @@ class DashboardViewModel( _state.update { it.copy(showVpnWarning = shouldShowVpnWarning()) } }.launchIn(viewModelScope) - events - .filterIsInstance() - .onEach { (type) -> toggleSection(type) } - .launchIn(viewModelScope) - - events - .filterIsInstance() - .onEach { startDescriptorsUpdates(null) } - .launchIn(viewModelScope) - - events - .filterIsInstance() - .onEach { - dismissDescriptorsUpdateNotice() - goToReviewDescriptorUpdates(null) - }.launchIn(viewModelScope) - - events - .filterIsInstance() - .onEach { - dismissDescriptorsUpdateNotice() - goToReviewDescriptorUpdates( - listOf( - (it.descriptor.source as? Descriptor.Source.Installed)?.value?.id - ?: return@onEach, - ), - ) - }.launchIn(viewModelScope) - - events - .filterIsInstance() - .onEach { dismissDescriptorsUpdateNotice() } - .launchIn(viewModelScope) - events .filterIsInstance() .onEach { @@ -192,58 +118,12 @@ class DashboardViewModel( events.tryEmit(event) } - private suspend fun toggleSection(type: DescriptorType) { - val collapsedSections = getPreference(SettingsKey.DESCRIPTOR_SECTIONS_COLLAPSED) - .first() - .toCollapsedSections() - val newCollapsedSections = if (collapsedSections.contains(type)) { - collapsedSections - type - } else { - collapsedSections + type - } - setPreference( - SettingsKey.DESCRIPTOR_SECTIONS_COLLAPSED, - newCollapsedSections.map { it.key }.toSet(), - ) - } - - private fun List.groupByType(collapsedSections: List) = - listOf( - DescriptorSection( - type = DescriptorType.Installed, - descriptors = filter { it.source is Descriptor.Source.Installed }, - isCollapsed = collapsedSections.contains(DescriptorType.Installed), - ), - DescriptorSection( - type = DescriptorType.Default, - descriptors = filter { it.source is Descriptor.Source.Default }, - isCollapsed = collapsedSections.contains(DescriptorType.Default), - ), - ) - - private fun Any?.toCollapsedSections() = - @Suppress("UNCHECKED_CAST") - (this as? Set)?.mapNotNull { DescriptorType.fromKey(it) }.orEmpty() - data class State( - val sections: List = emptyList(), val runBackgroundState: RunBackgroundState = RunBackgroundState.Idle(), val testRunErrors: List = emptyList(), val showVpnWarning: Boolean = false, - val availableUpdates: List = emptyList(), - val descriptorsUpdateOperationState: DescriptorUpdateOperationState = DescriptorUpdateOperationState.Idle, val showIgnoreBatteryOptimizationNotice: Boolean = false, - val canPullToRefresh: Boolean = true, - ) { - val isRefreshing: Boolean - get() = descriptorsUpdateOperationState == DescriptorUpdateOperationState.FetchingUpdates - - val isRefreshEnabled: Boolean - get() = sections - .firstOrNull { it.type == DescriptorType.Installed } - ?.descriptors - ?.any() == true - } + ) sealed interface Event { data object Resumed : Event @@ -260,35 +140,11 @@ class DashboardViewModel( val error: TestRunError, ) : Event - data class DescriptorClicked( - val descriptor: Descriptor, - ) : Event - - data class ToggleSection( - val type: DescriptorType, - ) : Event - - data class UpdateDescriptorClicked( - val descriptor: Descriptor, - ) : Event - - data object FetchUpdatedDescriptors : Event - - data object ReviewUpdatesClicked : Event - - data object CancelUpdatesClicked : Event - data object IgnoreBatteryOptimizationAccepted : Event data object IgnoreBatteryOptimizationDismissed : Event } - data class DescriptorSection( - val type: DescriptorType, - val descriptors: List, - val isCollapsed: Boolean = false, - ) - companion object { private val CHECK_VPN_WARNING_INTERVAL = 5.seconds } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt new file mode 100644 index 000000000..da3f713a0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt @@ -0,0 +1,208 @@ +package org.ooni.probe.ui.descriptors + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import ooniprobe.composeapp.generated.resources.Common_Collapse +import ooniprobe.composeapp.generated.resources.Common_Expand +import ooniprobe.composeapp.generated.resources.DescriptorUpdate_CheckUpdates +import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.Tests_Title +import ooniprobe.composeapp.generated.resources.ic_keyboard_arrow_down +import ooniprobe.composeapp.generated.resources.ic_keyboard_arrow_up +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.DescriptorType +import org.ooni.probe.data.models.DescriptorUpdateOperationState +import org.ooni.probe.ui.shared.TopBar +import org.ooni.probe.ui.shared.UpdateProgressStatus +import org.ooni.probe.ui.shared.VerticalScrollbar +import org.ooni.probe.ui.theme.AppTheme + +@Composable +fun DescriptorsScreen( + state: DescriptorsViewModel.State, + onEvent: (DescriptorsViewModel.Event) -> Unit, +) { + val pullRefreshState = rememberPullToRefreshState() + Box( + Modifier + .pullToRefresh( + isRefreshing = state.isRefreshing, + onRefresh = { onEvent(DescriptorsViewModel.Event.FetchUpdatedDescriptors) }, + state = pullRefreshState, + enabled = state.isRefreshEnabled && state.canPullToRefresh, + ).background(MaterialTheme.colorScheme.background) + .fillMaxSize(), + ) { + Column(Modifier.fillMaxSize()) { + TopBar( + title = { Text(stringResource(Res.string.Tests_Title)) }, + ) + + Box { + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier.testTag("Descriptors-List"), + contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp), + state = lazyListState, + ) { + val allSectionsHaveValues = state.sections.all { it.descriptors.any() } + state.sections.forEach { (type, descriptors, isCollapsed) -> + if (allSectionsHaveValues && descriptors.isNotEmpty()) { + item(type) { + TestDescriptorSectionTitle( + type = type, + isCollapsed = isCollapsed, + state = state, + onEvent = onEvent, + ) + } + } + if (isCollapsed) return@forEach + items(descriptors, key = { it.key }) { descriptor -> + TestDescriptorItem( + descriptor = descriptor, + onClick = { + onEvent( + DescriptorsViewModel.Event.DescriptorClicked(descriptor), + ) + }, + onUpdateClick = { + onEvent( + DescriptorsViewModel.Event.UpdateDescriptorClicked( + descriptor, + ), + ) + }, + ) + } + } + } + VerticalScrollbar( + state = lazyListState, + modifier = Modifier.align(Alignment.CenterEnd), + ) + } + } + + if (state.descriptorsUpdateOperationState != DescriptorUpdateOperationState.Idle) { + UpdateProgressStatus( + modifier = Modifier.align(Alignment.BottomCenter), + type = state.descriptorsUpdateOperationState, + onReviewLinkClicked = { onEvent(DescriptorsViewModel.Event.ReviewUpdatesClicked) }, + onCancelClicked = { onEvent(DescriptorsViewModel.Event.CancelUpdatesClicked) }, + ) + } + + PullToRefreshDefaults.Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = state.isRefreshing, + state = pullRefreshState, + ) + } +} + +@Composable +private fun TestDescriptorSectionTitle( + type: DescriptorType, + isCollapsed: Boolean, + state: DescriptorsViewModel.State, + onEvent: (DescriptorsViewModel.Event) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .clickable { onEvent(DescriptorsViewModel.Event.ToggleSection(type)) } + .padding(horizontal = 16.dp) + .defaultMinSize(minHeight = 40.dp) + .padding(vertical = 1.dp), + ) { + TestDescriptorTypeTitle(type) + Icon( + painterResource( + if (isCollapsed) { + Res.drawable.ic_keyboard_arrow_down + } else { + Res.drawable.ic_keyboard_arrow_up + }, + ), + contentDescription = stringResource( + if (isCollapsed) { + Res.string.Common_Expand + } else { + Res.string.Common_Collapse + }, + ) + " " + stringResource(type.title), + modifier = Modifier + .padding(horizontal = 8.dp) + .size(16.dp), + ) + Spacer(Modifier.weight(1f)) + if (type == DescriptorType.Installed && !state.canPullToRefresh) { + CheckUpdatesButton( + enabled = !state.isRefreshing, + onEvent = onEvent, + ) + } + } +} + +@Composable +private fun CheckUpdatesButton( + enabled: Boolean, + onEvent: (DescriptorsViewModel.Event) -> Unit, +) { + TextButton( + onClick = { onEvent(DescriptorsViewModel.Event.FetchUpdatedDescriptors) }, + enabled = enabled, + contentPadding = PaddingValues( + horizontal = 8.dp, + vertical = 4.dp, + ), + modifier = Modifier.defaultMinSize(minHeight = 32.dp), + ) { + Text( + stringResource(Res.string.DescriptorUpdate_CheckUpdates), + style = MaterialTheme.typography.labelMedium, + ) + } +} + +@Preview +@Composable +fun DashboardScreenPreview() { + AppTheme { + DescriptorsScreen( + state = DescriptorsViewModel.State(), + onEvent = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsViewModel.kt new file mode 100644 index 000000000..e0f468975 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsViewModel.kt @@ -0,0 +1,175 @@ +package org.ooni.probe.ui.descriptors + +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.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import org.ooni.probe.data.models.Descriptor +import org.ooni.probe.data.models.DescriptorType +import org.ooni.probe.data.models.DescriptorUpdateOperationState +import org.ooni.probe.data.models.DescriptorsUpdateState +import org.ooni.probe.data.models.InstalledTestDescriptorModel +import org.ooni.probe.data.models.SettingsKey + +class DescriptorsViewModel( + goToDescriptor: (String) -> Unit, + goToReviewDescriptorUpdates: (List?) -> Unit, + getTestDescriptors: () -> Flow>, + observeDescriptorUpdateState: () -> Flow, + startDescriptorsUpdates: suspend (List?) -> Unit, + dismissDescriptorsUpdateNotice: () -> Unit, + canPullToRefresh: Boolean, + private val getPreference: (SettingsKey) -> Flow, + private val setPreference: suspend (SettingsKey, Any?) -> Unit, +) : ViewModel() { + private val events = MutableSharedFlow(extraBufferCapacity = 1) + + private val _state = MutableStateFlow(State(canPullToRefresh = canPullToRefresh)) + val state = _state.asStateFlow() + + init { + observeDescriptorUpdateState() + .onEach { updates -> + _state.update { + it.copy( + availableUpdates = updates.availableUpdates.toList(), + descriptorsUpdateOperationState = updates.operationState, + ) + } + }.launchIn(viewModelScope) + + combine( + getTestDescriptors(), + getPreference(SettingsKey.DESCRIPTOR_SECTIONS_COLLAPSED), + ) { tests, collapsedSectionsPreference -> + val collapsedSections = collapsedSectionsPreference.toCollapsedSections() + _state.update { it.copy(sections = tests.groupByType(collapsedSections)) } + }.launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { event -> goToDescriptor(event.descriptor.key) } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { (type) -> toggleSection(type) } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { startDescriptorsUpdates(null) } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { + dismissDescriptorsUpdateNotice() + goToReviewDescriptorUpdates(null) + }.launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { + dismissDescriptorsUpdateNotice() + goToReviewDescriptorUpdates( + listOf( + (it.descriptor.source as? Descriptor.Source.Installed)?.value?.id + ?: return@onEach, + ), + ) + }.launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { dismissDescriptorsUpdateNotice() } + .launchIn(viewModelScope) + } + + fun onEvent(event: Event) { + events.tryEmit(event) + } + + private suspend fun toggleSection(type: DescriptorType) { + val collapsedSections = getPreference(SettingsKey.DESCRIPTOR_SECTIONS_COLLAPSED) + .first() + .toCollapsedSections() + val newCollapsedSections = if (collapsedSections.contains(type)) { + collapsedSections - type + } else { + collapsedSections + type + } + setPreference( + SettingsKey.DESCRIPTOR_SECTIONS_COLLAPSED, + newCollapsedSections.map { it.key }.toSet(), + ) + } + + private fun List.groupByType(collapsedSections: List) = + listOf( + DescriptorSection( + type = DescriptorType.Installed, + descriptors = filter { it.source is Descriptor.Source.Installed }, + isCollapsed = collapsedSections.contains(DescriptorType.Installed), + ), + DescriptorSection( + type = DescriptorType.Default, + descriptors = filter { it.source is Descriptor.Source.Default }, + isCollapsed = collapsedSections.contains(DescriptorType.Default), + ), + ) + + private fun Any?.toCollapsedSections() = + @Suppress("UNCHECKED_CAST") + (this as? Set)?.mapNotNull { DescriptorType.fromKey(it) }.orEmpty() + + data class State( + val sections: List = emptyList(), + val availableUpdates: List = emptyList(), + val descriptorsUpdateOperationState: DescriptorUpdateOperationState = DescriptorUpdateOperationState.Idle, + val canPullToRefresh: Boolean = true, + ) { + val isRefreshing: Boolean + get() = descriptorsUpdateOperationState == DescriptorUpdateOperationState.FetchingUpdates + + val isRefreshEnabled: Boolean + get() = sections + .firstOrNull { it.type == DescriptorType.Installed } + ?.descriptors + ?.any() == true + } + + sealed interface Event { + data class DescriptorClicked( + val descriptor: Descriptor, + ) : Event + + data class ToggleSection( + val type: DescriptorType, + ) : Event + + data class UpdateDescriptorClicked( + val descriptor: Descriptor, + ) : Event + + data object FetchUpdatedDescriptors : Event + + data object ReviewUpdatesClicked : Event + + data object CancelUpdatesClicked : Event + } + + data class DescriptorSection( + val type: DescriptorType, + val descriptors: List, + val isCollapsed: Boolean = false, + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/TestDescriptorItem.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/TestDescriptorItem.kt new file mode 100644 index 000000000..ab6923bfa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/TestDescriptorItem.kt @@ -0,0 +1,70 @@ +package org.ooni.probe.ui.descriptors + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.ic_chevron_right +import org.jetbrains.compose.resources.painterResource +import org.ooni.probe.data.models.Descriptor +import org.ooni.probe.data.models.UpdateStatus +import org.ooni.probe.ui.shared.ExpiredChip +import org.ooni.probe.ui.shared.UpdatesChip + +@Composable +fun TestDescriptorItem( + descriptor: Descriptor, + onClick: () -> Unit, + onUpdateClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clip(CardDefaults.shape) + .clickable { onClick() } + .testTag(descriptor.key) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.surfaceVariant, + shape = CardDefaults.shape, + ).padding(vertical = 8.dp, horizontal = 12.dp), + ) { + Column( + modifier = Modifier.weight(1f), + ) { + TestDescriptorLabel(descriptor) + + descriptor.shortDescription()?.let { shortDescription -> + Text( + shortDescription, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + if (descriptor.updateStatus is UpdateStatus.Updatable) { + UpdatesChip(onClick = onUpdateClick) + } + if (descriptor.isExpired) { + ExpiredChip() + } + Icon( + painter = painterResource(Res.drawable.ic_chevron_right), + contentDescription = null, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/TestDescriptorLabel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/TestDescriptorLabel.kt new file mode 100644 index 000000000..9d2d9144c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/TestDescriptorLabel.kt @@ -0,0 +1,46 @@ +package org.ooni.probe.ui.descriptors + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.ooni_empty_state +import org.jetbrains.compose.resources.painterResource +import org.ooni.probe.data.models.Descriptor + +@Composable +fun TestDescriptorLabel( + descriptor: Descriptor, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Icon( + painter = painterResource(descriptor.icon ?: Res.drawable.ooni_empty_state), + contentDescription = null, + tint = descriptor.color ?: Color.Unspecified, + modifier = Modifier + .padding(end = 8.dp) + .size(24.dp), + ) + Text( + descriptor.title(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/TestDescriptorTypeTitle.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/TestDescriptorTypeTitle.kt new file mode 100644 index 000000000..983e8223f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/TestDescriptorTypeTitle.kt @@ -0,0 +1,20 @@ +package org.ooni.probe.ui.descriptors + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.jetbrains.compose.resources.stringResource +import org.ooni.probe.data.models.DescriptorType + +@Composable +fun TestDescriptorTypeTitle( + type: DescriptorType, + modifier: Modifier = Modifier, +) { + Text( + stringResource(type.title).uppercase(), + style = MaterialTheme.typography.labelLarge, + modifier = modifier, + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomBarViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomBarViewModel.kt index 9921292ce..575fa5cf2 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomBarViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomBarViewModel.kt @@ -9,11 +9,14 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import org.ooni.probe.data.models.DescriptorUpdateOperationState +import org.ooni.probe.data.models.DescriptorsUpdateState import org.ooni.probe.data.models.RunBackgroundState class BottomBarViewModel( countAllNotViewedFlow: () -> Flow, runBackgroundStateFlow: () -> Flow, + observeDescriptorUpdateState: () -> Flow, ) : ViewModel() { private val _state = MutableStateFlow(State()) val state: StateFlow = _state.asStateFlow() @@ -28,10 +31,21 @@ class BottomBarViewModel( .onEach { runState -> _state.update { it.copy(areTestsRunning = runState !is RunBackgroundState.Idle) } }.launchIn(viewModelScope) + + observeDescriptorUpdateState() + .onEach { state -> + _state.update { + it.copy( + isDescriptorsReviewNecessary = state.operationState + == DescriptorUpdateOperationState.ReviewNecessaryNotice, + ) + } + }.launchIn(viewModelScope) } data class State( val notViewedCount: Long = 0L, val areTestsRunning: Boolean = false, + val isDescriptorsReviewNecessary: Boolean = false, ) } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt index 11767450e..7ce7df4a8 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt @@ -26,9 +26,11 @@ import ooniprobe.composeapp.generated.resources.Dashboard_Tab_Label import ooniprobe.composeapp.generated.resources.Res import ooniprobe.composeapp.generated.resources.Settings_Title import ooniprobe.composeapp.generated.resources.TestResults_Overview_Tab_Label +import ooniprobe.composeapp.generated.resources.Tests_Title import ooniprobe.composeapp.generated.resources.ic_dashboard import ooniprobe.composeapp.generated.resources.ic_history import ooniprobe.composeapp.generated.resources.ic_settings +import ooniprobe.composeapp.generated.resources.ic_tests import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.ooni.probe.MAIN_NAVIGATION_SCREENS @@ -48,7 +50,6 @@ fun BottomNavigationBar( modifier = customMinHeightModifier, ) { MAIN_NAVIGATION_SCREENS.forEach { screen -> - val screen = screen as Screen val isCurrentScreen = entry?.destination?.hasRoute(screen::class) == true NavigationBarItem( icon = { @@ -97,10 +98,12 @@ private fun NavigationBadgeBox( ) { BadgedBox( badge = { - if (state.notViewedCount > 0 && screen == Screen.Results) { + if (screen == Screen.Results && state.notViewedCount > 0) { val badgeText = if (state.notViewedCount > 9) "9+" else state.notViewedCount.toString() Badge { Text(badgeText) } + } else if (screen == Screen.Descriptors && state.isDescriptorsReviewNecessary) { + Badge() } }, content = content, @@ -111,6 +114,7 @@ private val Screen.titleRes get() = when (this) { Screen.Dashboard -> Res.string.Dashboard_Tab_Label + Screen.Descriptors -> Res.string.Tests_Title Screen.Results -> Res.string.TestResults_Overview_Tab_Label Screen.Settings -> Res.string.Settings_Title else -> throw IllegalArgumentException("Only main screens allowed in bottom navigation") @@ -120,6 +124,7 @@ private val Screen.iconRes get() = when (this) { Screen.Dashboard -> Res.drawable.ic_dashboard + Screen.Descriptors -> Res.drawable.ic_tests Screen.Results -> Res.drawable.ic_history Screen.Settings -> Res.drawable.ic_settings else -> throw IllegalArgumentException("Only main screens allowed in bottom navigation") 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 6082a7e68..4d2a81bc3 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 @@ -28,6 +28,7 @@ import org.ooni.probe.ui.descriptor.DescriptorScreen import org.ooni.probe.ui.descriptor.add.AddDescriptorScreen import org.ooni.probe.ui.descriptor.review.ReviewUpdatesScreen import org.ooni.probe.ui.descriptor.websites.DescriptorWebsitesViewModel +import org.ooni.probe.ui.descriptors.DescriptorsScreen import org.ooni.probe.ui.log.LogScreen import org.ooni.probe.ui.measurement.MeasurementRawScreen import org.ooni.probe.ui.measurement.MeasurementScreen @@ -80,6 +81,19 @@ fun Navigation( goToResults = { navController.navigateToMainScreen(Screen.Results) }, goToRunningTest = { navController.safeNavigate(Screen.RunningTest) }, goToRunTests = { navController.safeNavigate(Screen.RunTests) }, + ) + } + val state by viewModel.state.collectAsState() + DashboardScreen(state, viewModel::onEvent) + } + + composable { + val viewModel = viewModel { + dependencies.descriptorsViewModel( + goToOnboarding = { navController.goBackAndNavigate(Screen.Onboarding) }, + goToResults = { navController.navigateToMainScreen(Screen.Results) }, + goToRunningTest = { navController.safeNavigate(Screen.RunningTest) }, + goToRunTests = { navController.safeNavigate(Screen.RunTests) }, goToDescriptor = { descriptorKey -> navController.safeNavigate(Screen.Descriptor(descriptorKey)) }, @@ -89,7 +103,7 @@ fun Navigation( ) } val state by viewModel.state.collectAsState() - DashboardScreen(state, viewModel::onEvent) + DescriptorsScreen(state, viewModel::onEvent) } composable { 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 f9fb1fc94..3edbcef40 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 @@ -8,6 +8,8 @@ sealed interface Screen { @Serializable data object Dashboard : Screen + @Serializable data object Descriptors : Screen + @Serializable data object Results : Screen @Serializable data object Settings : Screen diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt index 4efa89448..049844ffa 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt @@ -79,7 +79,7 @@ import ooniprobe.composeapp.generated.resources.TestResults_Filter_NoTestsFound import ooniprobe.composeapp.generated.resources.TestResults_Filters_Title import ooniprobe.composeapp.generated.resources.TestResults_Overview_Hero_DataUsage import ooniprobe.composeapp.generated.resources.TestResults_Overview_Hero_Networks -import ooniprobe.composeapp.generated.resources.TestResults_Overview_Hero_Tests +import ooniprobe.composeapp.generated.resources.TestResults_Overview_Hero_Results import ooniprobe.composeapp.generated.resources.TestResults_Overview_NoTestsHaveBeenRun import ooniprobe.composeapp.generated.resources.TestResults_Overview_Title import ooniprobe.composeapp.generated.resources.TestResults_Summary_Performance_Hero_Download @@ -446,7 +446,7 @@ private fun Summary(summary: ResultsViewModel.Summary?) { horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - stringResource(Res.string.TestResults_Overview_Hero_Tests), + stringResource(Res.string.TestResults_Overview_Hero_Results), style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(bottom = 8.dp), ) diff --git a/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/dashboard/DashboardScreenTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreenTest.kt similarity index 78% rename from composeApp/src/commonTest/kotlin/org/ooni/probe/ui/dashboard/DashboardScreenTest.kt rename to composeApp/src/commonTest/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreenTest.kt index d9de30803..a82dde302 100644 --- a/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/dashboard/DashboardScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreenTest.kt @@ -1,4 +1,4 @@ -package org.ooni.probe.ui.dashboard +package org.ooni.probe.ui.descriptors import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.onNodeWithText @@ -6,11 +6,13 @@ import androidx.compose.ui.test.runComposeUiTest import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import org.ooni.probe.data.models.DescriptorType +import org.ooni.probe.ui.descriptors.DescriptorsScreen +import org.ooni.probe.ui.descriptors.DescriptorsViewModel import org.ooni.testing.TestLifecycleOwner import org.ooni.testing.factories.DescriptorFactory import kotlin.test.Test -class DashboardScreenTest { +class DescriptorsScreenTest { @Test fun showTestDescriptors() = runComposeUiTest { @@ -19,11 +21,11 @@ class DashboardScreenTest { setContent { CompositionLocalProvider(LocalLifecycleOwner provides TestLifecycleOwner(Lifecycle.State.RESUMED)) { - DashboardScreen( + DescriptorsScreen( state = - DashboardViewModel.State( + DescriptorsViewModel.State( sections = listOf( - DashboardViewModel.DescriptorSection( + DescriptorsViewModel.DescriptorSection( type = DescriptorType.Installed, descriptors = listOf(descriptor), ), diff --git a/composeApp/src/desktopMain/resources/macos/libnetworktypefinder.dylib b/composeApp/src/desktopMain/resources/macos/libnetworktypefinder.dylib old mode 100755 new mode 100644 diff --git a/composeApp/src/desktopMain/resources/macos/libupdatebridge.dylib b/composeApp/src/desktopMain/resources/macos/libupdatebridge.dylib index c0e683a52269ade5a731e982a2c31a7667b03c04..e77e53e4c4fe6fe947bd6883da8090726ade80c5 100755 GIT binary patch delta 105 zcmca{gyqH&mJJ^`^iP~A{>{*3s4(x~f^08SPIn^)1_lKnW&~me1}QKGv6(?!mZQz` zoZIC&89$3DEO*VheEi<)y*q>%0?YL7Y6fi-y2z#T*QNYgQADH68>{U`3XEz@0Itg* A-2eap delta 105 zcmca{gyqH&mJJ^`^ey~9O#PK*8N6CCS!40j<_04M1_lKnW&~me1_232W1Wzx>Bm~SR6M_2Dz_xN_fyI@(ZaN`HwGrqmpZlu7d#smNc CU?eR7 From 814ea3778f346d10d42b5a974f8416c13d40a858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Wed, 15 Oct 2025 10:22:55 +0100 Subject: [PATCH 02/21] Auto-run button --- .../composeResources/drawable/ic_auto_run.xml | 10 ++ .../values/strings-common.xml | 2 + .../kotlin/org/ooni/probe/di/Dependencies.kt | 2 + .../probe/ui/dashboard/DashboardScreen.kt | 94 +++++++++++++++++++ .../probe/ui/dashboard/DashboardViewModel.kt | 40 +++++--- .../ui/dashboard/RunBackgroundStateSection.kt | 28 +----- .../ooni/probe/ui/navigation/Navigation.kt | 5 + 7 files changed, 144 insertions(+), 37 deletions(-) create mode 100644 composeApp/src/commonMain/composeResources/drawable/ic_auto_run.xml diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_auto_run.xml b/composeApp/src/commonMain/composeResources/drawable/ic_auto_run.xml new file mode 100644 index 000000000..56f0866dc --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_auto_run.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index 3cd003720..3ff3f8efd 100644 --- a/composeApp/src/commonMain/composeResources/values/strings-common.xml +++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml @@ -23,6 +23,8 @@ OONI Tests OONI Run Links + enabled]]> + disabled]]> Run finished. Tap to view results. Created by %1$s on %2$s Uninstall Link 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 f8a6ad119..ea3bed005 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -565,11 +565,13 @@ class Dependencies( goToResults: () -> Unit, goToRunningTest: () -> Unit, goToRunTests: () -> Unit, + goToTestSettings: () -> Unit, ) = DashboardViewModel( goToOnboarding = goToOnboarding, goToResults = goToResults, goToRunningTest = goToRunningTest, goToRunTests = goToRunTests, + goToTestSettings = goToTestSettings, getFirstRun = getFirstRun::invoke, observeRunBackgroundState = runBackgroundStateManager.observeState(), observeTestRunErrors = runBackgroundStateManager.observeErrors(), 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 604a883c5..34116a466 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 @@ -11,35 +11,52 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect +import ooniprobe.composeapp.generated.resources.Dashboard_AutoRun_Disabled +import ooniprobe.composeapp.generated.resources.Dashboard_AutoRun_Enabled +import ooniprobe.composeapp.generated.resources.Dashboard_Overview_LatestTest +import ooniprobe.composeapp.generated.resources.Dashboard_RunV2_RunFinished import ooniprobe.composeapp.generated.resources.Modal_DisableVPN_Title import ooniprobe.composeapp.generated.resources.Res import ooniprobe.composeapp.generated.resources.app_name import ooniprobe.composeapp.generated.resources.dashboard_arc +import ooniprobe.composeapp.generated.resources.ic_auto_run import ooniprobe.composeapp.generated.resources.ic_warning import ooniprobe.composeapp.generated.resources.logo_probe 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.RunBackgroundState import org.ooni.probe.ui.shared.IgnoreBatteryOptimizationDialog import org.ooni.probe.ui.shared.TestRunErrorMessages import org.ooni.probe.ui.shared.VerticalScrollbar import org.ooni.probe.ui.shared.isHeightCompact +import org.ooni.probe.ui.shared.relativeDateTime import org.ooni.probe.ui.theme.AppTheme +import org.ooni.probe.ui.theme.LocalCustomColors +import org.ooni.probe.ui.theme.customColors @Composable fun DashboardScreen( @@ -84,6 +101,8 @@ fun DashboardScreen( modifier = Modifier.fillMaxWidth(), ) { RunBackgroundStateSection(state.runBackgroundState, onEvent) + + AutoRunButton(isAutoRunEnabled = state.isAutoRunEnabled, onEvent) } } @@ -96,6 +115,28 @@ fun DashboardScreen( .verticalScroll(scrollState) .fillMaxSize(), ) { + if (state.runBackgroundState is RunBackgroundState.Idle) { + state.runBackgroundState.lastTestAt?.let { lastTestAt -> + Text( + text = stringResource(Res.string.Dashboard_Overview_LatestTest) + " " + lastTestAt.relativeDateTime(), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(top = 4.dp), + ) + } + if (state.runBackgroundState.justFinishedTest) { + Button( + onClick = { onEvent(DashboardViewModel.Event.SeeResultsClicked) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.customColors.success, + contentColor = MaterialTheme.customColors.onSuccess, + ), + modifier = Modifier.padding(top = 4.dp), + ) { + Text(stringResource(Res.string.Dashboard_RunV2_RunFinished)) + } + } + } + if (state.showVpnWarning) { VpnWarning() } @@ -124,6 +165,59 @@ fun DashboardScreen( } } +@Composable +private fun AutoRunButton( + isAutoRunEnabled: Boolean, + onEvent: (DashboardViewModel.Event) -> Unit, +) { + TextButton( + onClick = { onEvent(DashboardViewModel.Event.AutoRunClicked) }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface), + modifier = Modifier.padding(top = 4.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painterResource(Res.drawable.ic_auto_run), + contentDescription = null, + modifier = Modifier.padding(end = 8.dp).size(16.dp), + ) + Text( + text = autoRunText(isAutoRunEnabled), + style = MaterialTheme.typography.labelLarge, + ) + } + } +} + +@Composable +private fun autoRunText(isAutoRunEnabled: Boolean): AnnotatedString { + val baseString = stringResource( + if (isAutoRunEnabled) { + Res.string.Dashboard_AutoRun_Enabled + } else { + Res.string.Dashboard_AutoRun_Disabled + }, + ) + val startSection = baseString.indexOf("") + val endSection = baseString.indexOf("") + val sectionColor = if (isAutoRunEnabled) { + LocalCustomColors.current.success + } else { + MaterialTheme.colorScheme.error + } + return if (startSection != -1 && endSection != -1 && startSection + 3 < endSection) { + buildAnnotatedString { + append(baseString.substring(0, startSection)) + withStyle(style = SpanStyle(color = sectionColor)) { + append(baseString.substring(startSection + 3, endSection)) + } + append(baseString.substring(endSection + 4, baseString.length)) + } + } else { + buildAnnotatedString { append(baseString) } + } +} + @Composable private fun VpnWarning() { Surface( 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 9ce6d67b9..2308a969a 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 @@ -26,6 +26,7 @@ class DashboardViewModel( goToResults: () -> Unit, goToRunningTest: () -> Unit, goToRunTests: () -> Unit, + goToTestSettings: () -> Unit, getFirstRun: () -> Flow, observeRunBackgroundState: Flow, observeTestRunErrors: Flow, @@ -44,14 +45,23 @@ class DashboardViewModel( .onEach { firstRun -> if (firstRun) goToOnboarding() } .launchIn(viewModelScope) + getAutoRunSettings() + .onEach { autoRunParameters -> + _state.update { + it.copy(isAutoRunEnabled = autoRunParameters is AutoRunParameters.Enabled) + } + }.launchIn(viewModelScope) + getAutoRunSettings() .take(1) .onEach { autoRunParameters -> - if (autoRunParameters is AutoRunParameters.Enabled && - batteryOptimization.isSupported && - !batteryOptimization.isIgnoring - ) { - _state.update { it.copy(showIgnoreBatteryOptimizationNotice = true) } + _state.update { + it.copy( + showIgnoreBatteryOptimizationNotice = + autoRunParameters is AutoRunParameters.Enabled && + batteryOptimization.isSupported && + !batteryOptimization.isIgnoring, + ) } }.launchIn(viewModelScope) @@ -66,17 +76,22 @@ class DashboardViewModel( }.launchIn(viewModelScope) events - .filterIsInstance() + .filterIsInstance() .onEach { goToRunTests() } .launchIn(viewModelScope) events - .filterIsInstance() + .filterIsInstance() .onEach { goToRunningTest() } .launchIn(viewModelScope) events - .filterIsInstance() + .filterIsInstance() + .onEach { goToTestSettings() } + .launchIn(viewModelScope) + + events + .filterIsInstance() .onEach { goToResults() } .launchIn(viewModelScope) @@ -120,6 +135,7 @@ class DashboardViewModel( data class State( val runBackgroundState: RunBackgroundState = RunBackgroundState.Idle(), + val isAutoRunEnabled: Boolean = false, val testRunErrors: List = emptyList(), val showVpnWarning: Boolean = false, val showIgnoreBatteryOptimizationNotice: Boolean = false, @@ -130,11 +146,13 @@ class DashboardViewModel( data object Paused : Event - data object RunTestsClick : Event + data object RunTestsClicked : Event + + data object RunningTestClicked : Event - data object RunningTestClick : Event + data object AutoRunClicked : Event - data object SeeResultsClick : Event + data object SeeResultsClicked : Event data class ErrorDisplayed( val error: TestRunError, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/RunBackgroundStateSection.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/RunBackgroundStateSection.kt index 010133715..068380271 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/RunBackgroundStateSection.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/RunBackgroundStateSection.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -21,8 +20,6 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import ooniprobe.composeapp.generated.resources.Dashboard_Overview_LatestTest -import ooniprobe.composeapp.generated.resources.Dashboard_RunV2_RunFinished import ooniprobe.composeapp.generated.resources.Dashboard_Running_EstimatedTimeLeft import ooniprobe.composeapp.generated.resources.Dashboard_Running_Running import ooniprobe.composeapp.generated.resources.Dashboard_Running_Stopping_Notice @@ -38,9 +35,7 @@ import org.ooni.engine.models.TestType import org.ooni.probe.data.models.RunBackgroundState import org.ooni.probe.domain.UploadMissingMeasurements import org.ooni.probe.ui.shared.format -import org.ooni.probe.ui.shared.relativeDateTime import org.ooni.probe.ui.theme.AppTheme -import org.ooni.probe.ui.theme.customColors @Composable fun RunBackgroundStateSection( @@ -61,7 +56,7 @@ private fun Idle( onEvent: (DashboardViewModel.Event) -> Unit, ) { OutlinedButton( - onClick = { onEvent(DashboardViewModel.Event.RunTestsClick) }, + onClick = { onEvent(DashboardViewModel.Event.RunTestsClicked) }, colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.onPrimary, @@ -81,25 +76,6 @@ private fun Idle( modifier = Modifier.padding(start = 8.dp), ) } - state.lastTestAt?.let { lastTestAt -> - Text( - text = stringResource(Res.string.Dashboard_Overview_LatestTest) + " " + lastTestAt.relativeDateTime(), - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(top = 4.dp), - ) - } - if (state.justFinishedTest) { - Button( - onClick = { onEvent(DashboardViewModel.Event.SeeResultsClick) }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.customColors.success, - contentColor = MaterialTheme.customColors.onSuccess, - ), - modifier = Modifier.padding(top = 4.dp), - ) { - Text(stringResource(Res.string.Dashboard_RunV2_RunFinished)) - } - } } @Composable @@ -173,7 +149,7 @@ private fun RunningTests( Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .clickable { onEvent(DashboardViewModel.Event.RunningTestClick) } + .clickable { onEvent(DashboardViewModel.Event.RunningTestClicked) } .padding(horizontal = 16.dp) .padding(top = 32.dp, bottom = 8.dp), ) { 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 4d2a81bc3..d4c67185b 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 @@ -81,6 +81,11 @@ fun Navigation( goToResults = { navController.navigateToMainScreen(Screen.Results) }, goToRunningTest = { navController.safeNavigate(Screen.RunningTest) }, goToRunTests = { navController.safeNavigate(Screen.RunTests) }, + goToTestSettings = { + navController.safeNavigate( + Screen.SettingsCategory(PreferenceCategoryKey.TEST_OPTIONS.value), + ) + }, ) } val state by viewModel.state.collectAsState() From 2a2ab2d0d2c77587b21b25445fedb0edb49e835e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Wed, 15 Oct 2025 17:45:15 +0100 Subject: [PATCH 03/21] Last results notice --- .../org/ooni/probe/background/RunWorker.kt | 2 +- .../values/strings-common.xml | 12 +- .../probe/background/RunBackgroundTask.kt | 4 +- .../kotlin/org/ooni/probe/data/models/Run.kt | 13 ++ .../probe/data/models/RunBackgroundState.kt | 6 +- .../org/ooni/probe/data/models/SettingsKey.kt | 1 + .../data/repositories/PreferenceRepository.kt | 4 + .../data/repositories/ResultRepository.kt | 10 +- .../kotlin/org/ooni/probe/di/Dependencies.kt | 39 +++-- .../ooni/probe/domain/BootstrapPreferences.kt | 1 + .../org/ooni/probe/domain/GetSettings.kt | 1 + .../domain/MarkJustFinishedTestAsSeen.kt | 17 -- .../probe/domain/RunBackgroundStateManager.kt | 29 +--- .../org/ooni/probe/domain/RunDescriptors.kt | 4 +- .../domain/{ => results}/DeleteOldResults.kt | 2 +- .../domain/{ => results}/DeleteResults.kt | 2 +- .../probe/domain/results/DismissLastRun.kt | 19 +++ .../ooni/probe/domain/results/GetLastRun.kt | 69 ++++++++ .../probe/domain/{ => results}/GetResult.kt | 2 +- .../probe/domain/{ => results}/GetResults.kt | 2 +- .../ooni/probe/ui/dashboard/DashboardCard.kt | 75 +++++++++ .../probe/ui/dashboard/DashboardScreen.kt | 154 ++++++++++++++---- .../probe/ui/dashboard/DashboardViewModel.kt | 26 ++- .../ui/dashboard/RunBackgroundStateSection.kt | 2 +- .../ooni/probe/ui/navigation/Navigation.kt | 4 - .../ooni/probe/ui/results/ResultsScreen.kt | 5 - .../ooni/probe/ui/results/ResultsViewModel.kt | 8 - .../ooni/probe/ui/running/RunningViewModel.kt | 9 +- .../commonMain/sqldelight/migrations/14.sqm | 44 +++++ .../sqldelight/org/ooni/probe/data/Result.sq | 117 +++++++------ .../probe/background/RunBackgroundTaskTest.kt | 6 +- .../probe/ui/results/ResultsScreenTest.kt | 14 -- .../desktopMain/kotlin/org/ooni/probe/Main.kt | 4 +- 33 files changed, 501 insertions(+), 206 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/Run.kt delete mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/domain/MarkJustFinishedTestAsSeen.kt rename composeApp/src/commonMain/kotlin/org/ooni/probe/domain/{ => results}/DeleteOldResults.kt (97%) rename composeApp/src/commonMain/kotlin/org/ooni/probe/domain/{ => results}/DeleteResults.kt (96%) create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/DismissLastRun.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/GetLastRun.kt rename composeApp/src/commonMain/kotlin/org/ooni/probe/domain/{ => results}/GetResult.kt (97%) rename composeApp/src/commonMain/kotlin/org/ooni/probe/domain/{ => results}/GetResults.kt (98%) create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt create mode 100644 composeApp/src/commonMain/sqldelight/migrations/14.sqm diff --git a/composeApp/src/androidMain/kotlin/org/ooni/probe/background/RunWorker.kt b/composeApp/src/androidMain/kotlin/org/ooni/probe/background/RunWorker.kt index baa42b14a..8d6dacee1 100644 --- a/composeApp/src/androidMain/kotlin/org/ooni/probe/background/RunWorker.kt +++ b/composeApp/src/androidMain/kotlin/org/ooni/probe/background/RunWorker.kt @@ -88,7 +88,7 @@ class RunWorker( } else { Logger.i("Run Worker: cancelled") } - setRunBackgroundState { RunBackgroundState.Idle() } + setRunBackgroundState { RunBackgroundState.Idle } } finally { notificationManager.cancel(NOTIFICATION_ID) unregisterReceiver() diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index 3ff3f8efd..e758fe25a 100644 --- a/composeApp/src/commonMain/composeResources/values/strings-common.xml +++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml @@ -2,7 +2,7 @@ Dashboard - Last test: + Estimated: Choose websites @@ -12,6 +12,12 @@ Finishing the currently pending tests, please wait… Proxy in use + Last Results + See results + + enabled]]> + disabled]]> + Run tests Select the tests to run Select all tests @@ -23,9 +29,6 @@ OONI Tests OONI Run Links - enabled]]> - disabled]]> - Run finished. Tap to view results. Created by %1$s on %2$s Uninstall Link Previous revisions @@ -439,6 +442,7 @@ Clear Next Previous + Dismiss Today Yesterday diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/background/RunBackgroundTask.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/background/RunBackgroundTask.kt index b1d2307f9..42c7d7374 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/background/RunBackgroundTask.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/background/RunBackgroundTask.kt @@ -60,7 +60,7 @@ class RunBackgroundTask( } if (spec is RunSpecification.OnlyUploadMissingResults) { - setRunBackgroundState { RunBackgroundState.Idle() } + setRunBackgroundState { RunBackgroundState.Idle } return@withTransaction } @@ -109,7 +109,7 @@ class RunBackgroundTask( cancelListenerCallback?.dismiss() if (isCancelled) { - updateState(RunBackgroundState.Idle()) + updateState(RunBackgroundState.Idle) return true } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/Run.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/Run.kt new file mode 100644 index 000000000..3e961bbe4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/Run.kt @@ -0,0 +1,13 @@ +package org.ooni.probe.data.models + +data class Run( + val results: List, +) { + val startTime get() = results.first().result.startTime + + val measurementCounts = MeasurementCounts( + done = results.sumOf { it.measurementCounts.done }, + failed = results.sumOf { it.measurementCounts.failed }, + anomaly = results.sumOf { it.measurementCounts.anomaly }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/RunBackgroundState.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/RunBackgroundState.kt index 501f3d175..5860f7329 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/RunBackgroundState.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/RunBackgroundState.kt @@ -1,16 +1,12 @@ package org.ooni.probe.data.models -import kotlinx.datetime.LocalDateTime import org.ooni.engine.models.TestType import org.ooni.probe.domain.UploadMissingMeasurements import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds sealed interface RunBackgroundState { - data class Idle( - val lastTestAt: LocalDateTime? = null, - val justFinishedTest: Boolean = false, - ) : RunBackgroundState + data object Idle : RunBackgroundState data class UploadingMissingResults( val state: UploadMissingMeasurements.State, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/SettingsKey.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/SettingsKey.kt index a96f31af9..46b969b0c 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/SettingsKey.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/SettingsKey.kt @@ -67,6 +67,7 @@ enum class SettingsKey( FIRST_RUN("first_run"), CHOSEN_WEBSITES("chosen_websites"), DESCRIPTOR_SECTIONS_COLLAPSED("descriptor_sections_collapsed"), + LAST_RUN_DISMISSED("last_run_dismissed"), ROUTE("route"), diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/PreferenceRepository.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/PreferenceRepository.kt index ac65480b1..d11c1d83c 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/PreferenceRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/PreferenceRepository.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import kotlinx.coroutines.flow.Flow @@ -80,6 +81,9 @@ class PreferenceRepository( SettingsKey.DELETE_OLD_RESULTS_THRESHOLD, -> PreferenceKey.IntKey(intPreferencesKey(preferenceKey)) + SettingsKey.LAST_RUN_DISMISSED, + -> PreferenceKey.LongKey(longPreferencesKey(preferenceKey)) + SettingsKey.LEGACY_PROXY_HOSTNAME, SettingsKey.LEGACY_PROXY_PROTOCOL, SettingsKey.PROXY_SELECTED, 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 0eaa114ff..66a92edfa 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 @@ -13,7 +13,6 @@ import org.ooni.engine.models.TaskOrigin import org.ooni.probe.Database import org.ooni.probe.data.Network import org.ooni.probe.data.Result -import org.ooni.probe.data.SelectAllWithNetwork import org.ooni.probe.data.SelectByIdWithNetwork import org.ooni.probe.data.models.InstalledTestDescriptorModel import org.ooni.probe.data.models.MeasurementCounts @@ -61,6 +60,13 @@ class ResultRepository( .mapToOneOrNull(backgroundContext) .map { it?.toModel() } + fun getLast(count: Int): Flow> = + database.resultQueries + .selectLast(count.toLong()) + .asFlow() + .mapToList(backgroundContext) + .map { list -> list.mapNotNull { it.toModel() } } + fun getLastDoneByDescriptor(descriptorKey: String): Flow = database.resultQueries .selectLastDoneByDescriptor(descriptorKey) @@ -198,7 +204,7 @@ class ResultRepository( ) } - private fun SelectAllWithNetwork.toModel(): ResultWithNetworkAndAggregates? { + private fun org.ooni.probe.data.ResultWithNetworkAndAggregates.toModel(): ResultWithNetworkAndAggregates? { return ResultWithNetworkAndAggregates( result = Result( id = id ?: return null, 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 ea3bed005..1a9d01468 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -48,8 +48,6 @@ import org.ooni.probe.domain.BootstrapPreferences import org.ooni.probe.domain.CheckAutoRunConstraints import org.ooni.probe.domain.ClearStorage import org.ooni.probe.domain.DeleteMeasurementsWithoutResult -import org.ooni.probe.domain.DeleteOldResults -import org.ooni.probe.domain.DeleteResults import org.ooni.probe.domain.DownloadUrls import org.ooni.probe.domain.FinishInProgressData import org.ooni.probe.domain.GetAutoRunSettings @@ -60,11 +58,8 @@ import org.ooni.probe.domain.GetEnginePreferences import org.ooni.probe.domain.GetFirstRun import org.ooni.probe.domain.GetLastResultOfDescriptor import org.ooni.probe.domain.GetMeasurementsNotUploaded -import org.ooni.probe.domain.GetResult -import org.ooni.probe.domain.GetResults import org.ooni.probe.domain.GetSettings import org.ooni.probe.domain.GetStorageUsed -import org.ooni.probe.domain.MarkJustFinishedTestAsSeen import org.ooni.probe.domain.ObserveAndConfigureAutoRun import org.ooni.probe.domain.ObserveAndConfigureAutoUpdate import org.ooni.probe.domain.RunBackgroundStateManager @@ -90,6 +85,12 @@ import org.ooni.probe.domain.descriptors.SaveTestDescriptors import org.ooni.probe.domain.descriptors.UndoRejectedDescriptorUpdate import org.ooni.probe.domain.proxy.ProxyManager import org.ooni.probe.domain.proxy.TestProxy +import org.ooni.probe.domain.results.DeleteOldResults +import org.ooni.probe.domain.results.DeleteResults +import org.ooni.probe.domain.results.DismissLastRun +import org.ooni.probe.domain.results.GetLastRun +import org.ooni.probe.domain.results.GetResult +import org.ooni.probe.domain.results.GetResults import org.ooni.probe.shared.PlatformInfo import org.ooni.probe.shared.monitoring.AppLogger import org.ooni.probe.shared.monitoring.CrashMonitoring @@ -292,6 +293,12 @@ class Dependencies( updateState = descriptorUpdateStateManager::update, ) } + private val dismissLastRun by lazy { + DismissLastRun( + getLastRun = getLastRun::invoke, + setPreference = preferenceRepository::setValueByKey, + ) + } private val fetchDescriptor by lazy { FetchDescriptor( engineHttpDo = engine::httpDo, @@ -328,6 +335,12 @@ class Dependencies( getResultById = getResult::invoke, ) } + private val getLastRun by lazy { + GetLastRun( + getLastResults = resultRepository::getLast, + getPreference = preferenceRepository::getValueByKey, + ) + } private val getResults by lazy { GetResults( resultRepository::list, @@ -390,9 +403,6 @@ class Dependencies( val markAppReviewAsShown by lazy { MarkAppReviewAsShown(setShownAt = appReviewRepository::setShownAt) } - private val markJustFinishedTestAsSeen by lazy { - MarkJustFinishedTestAsSeen(setRunBackgroundState = runBackgroundStateManager::updateState) - } val observeAndConfigureAutoRun by lazy { ObserveAndConfigureAutoRun( backgroundContext = backgroundContext, @@ -468,7 +478,7 @@ class Dependencies( private val shouldShowVpnWarning by lazy { ShouldShowVpnWarning(preferenceRepository, networkTypeFinder::invoke) } - val runBackgroundStateManager by lazy { RunBackgroundStateManager(resultRepository.getLatest()) } + val runBackgroundStateManager by lazy { RunBackgroundStateManager() } private val undoRejectedDescriptorUpdate by lazy { UndoRejectedDescriptorUpdate( updateDescriptorRejectedRevision = testDescriptorRepository::updateRejectedRevision, @@ -573,18 +583,16 @@ class Dependencies( goToRunTests = goToRunTests, goToTestSettings = goToTestSettings, getFirstRun = getFirstRun::invoke, - observeRunBackgroundState = runBackgroundStateManager.observeState(), - observeTestRunErrors = runBackgroundStateManager.observeErrors(), + observeRunBackgroundState = runBackgroundStateManager::observeState, + observeTestRunErrors = runBackgroundStateManager::observeErrors, shouldShowVpnWarning = shouldShowVpnWarning::invoke, getAutoRunSettings = getAutoRunSettings::invoke, + getLastRun = getLastRun::invoke, + dismissLastRun = dismissLastRun::invoke, batteryOptimization = batteryOptimization, ) fun descriptorsViewModel( - goToOnboarding: () -> Unit, - goToResults: () -> Unit, - goToRunningTest: () -> Unit, - goToRunTests: () -> Unit, goToDescriptor: (String) -> Unit, goToReviewDescriptorUpdates: (List?) -> Unit, ) = DescriptorsViewModel( @@ -683,7 +691,6 @@ class Dependencies( getNetworks = networkRepository::list, deleteResultsByFilter = deleteResults::byFilter, deleteResults = deleteResults::byIds, - markJustFinishedTestAsSeen = markJustFinishedTestAsSeen::invoke, markAsViewed = resultRepository::markAllAsViewed, ) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/BootstrapPreferences.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/BootstrapPreferences.kt index 6e967a2b7..f26fef2de 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/BootstrapPreferences.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/BootstrapPreferences.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.first import org.ooni.probe.data.models.Descriptor import org.ooni.probe.data.models.SettingsKey import org.ooni.probe.data.repositories.PreferenceRepository +import org.ooni.probe.domain.results.DeleteOldResults class BootstrapPreferences( private val preferencesRepository: PreferenceRepository, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetSettings.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetSettings.kt index e93677600..82291402d 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetSettings.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetSettings.kt @@ -68,6 +68,7 @@ import org.ooni.probe.data.models.SettingsCategoryItem import org.ooni.probe.data.models.SettingsItem import org.ooni.probe.data.models.SettingsKey import org.ooni.probe.data.repositories.PreferenceRepository +import org.ooni.probe.domain.results.DeleteOldResults import org.ooni.probe.ui.settings.category.SettingsDescription import org.ooni.probe.ui.settings.donate.DONATE_SETTINGS_ITEM import org.ooni.probe.ui.shared.format diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/MarkJustFinishedTestAsSeen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/MarkJustFinishedTestAsSeen.kt deleted file mode 100644 index d4af3ec3f..000000000 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/MarkJustFinishedTestAsSeen.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.ooni.probe.domain - -import org.ooni.probe.data.models.RunBackgroundState - -class MarkJustFinishedTestAsSeen( - private val setRunBackgroundState: ((RunBackgroundState) -> RunBackgroundState) -> Unit, -) { - operator fun invoke() { - setRunBackgroundState { state -> - if (state is RunBackgroundState.Idle && state.justFinishedTest) { - state.copy(justFinishedTest = false) - } else { - state - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunBackgroundStateManager.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunBackgroundStateManager.kt index 45f536a4c..2b7f38966 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunBackgroundStateManager.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunBackgroundStateManager.kt @@ -1,37 +1,24 @@ package org.ooni.probe.domain -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update -import org.ooni.probe.data.models.ResultModel -import org.ooni.probe.data.models.TestRunError import org.ooni.probe.data.models.RunBackgroundState +import org.ooni.probe.data.models.TestRunError -class RunBackgroundStateManager( - private val getLatestResult: Flow, -) { - private val state = MutableStateFlow(RunBackgroundState.Idle()) +class RunBackgroundStateManager { + private val state = MutableStateFlow(RunBackgroundState.Idle) private val errors = MutableSharedFlow(extraBufferCapacity = 1) private val cancelListeners = mutableListOf<() -> Unit>() // State - fun observeState() = - state - .asStateFlow() - .onStart { - state.update { value -> - if (value !is RunBackgroundState.Idle || value.lastTestAt != null) return@update value - RunBackgroundState.Idle(lastTestAt = getLatestResult.first()?.startTime) - } - } - - fun updateState(update: (RunBackgroundState) -> RunBackgroundState) = state.update(update) + fun observeState() = state.asSharedFlow() + + fun updateState(update: (RunBackgroundState) -> RunBackgroundState) { + state.update(update) + } // Errors 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 3e8cf64e0..e0069eb0a 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunDescriptors.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunDescriptors.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.last import kotlinx.coroutines.launch -import kotlinx.datetime.LocalDateTime import org.ooni.engine.Engine.MkException import org.ooni.engine.models.EnginePreferences import org.ooni.engine.models.NetworkType @@ -22,7 +21,6 @@ import org.ooni.probe.data.models.TestRunError import org.ooni.probe.data.models.UrlModel import org.ooni.probe.domain.proxy.TestProxy import org.ooni.probe.shared.monitoring.Instrumentation -import org.ooni.probe.shared.now import kotlin.time.Duration class RunDescriptors( @@ -78,7 +76,7 @@ class RunDescriptors( } catch (_: Exception) { // Exceptions were logged in the Engine } finally { - setRunBackgroundState { RunBackgroundState.Idle(LocalDateTime.now(), true) } + setRunBackgroundState { RunBackgroundState.Idle } finishInProgressData() } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/DeleteOldResults.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/DeleteOldResults.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/org/ooni/probe/domain/DeleteOldResults.kt rename to composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/DeleteOldResults.kt index 280a51915..820f87406 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/DeleteOldResults.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/DeleteOldResults.kt @@ -1,4 +1,4 @@ -package org.ooni.probe.domain +package org.ooni.probe.domain.results import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/DeleteResults.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/DeleteResults.kt similarity index 96% rename from composeApp/src/commonMain/kotlin/org/ooni/probe/domain/DeleteResults.kt rename to composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/DeleteResults.kt index 62301449e..372e0e402 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/DeleteResults.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/DeleteResults.kt @@ -1,4 +1,4 @@ -package org.ooni.probe.domain +package org.ooni.probe.domain.results import okio.Path import okio.Path.Companion.toPath diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/DismissLastRun.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/DismissLastRun.kt new file mode 100644 index 000000000..724b00308 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/DismissLastRun.kt @@ -0,0 +1,19 @@ +package org.ooni.probe.domain.results + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import org.ooni.probe.data.models.Run +import org.ooni.probe.data.models.SettingsKey + +class DismissLastRun( + private val getLastRun: () -> Flow, + private val setPreference: suspend (SettingsKey, Any) -> Unit, +) { + suspend operator fun invoke() { + val lastRun = getLastRun().first() ?: return + val firstResultId = lastRun.results + .first() + .result.id ?: return + setPreference(SettingsKey.LAST_RUN_DISMISSED, firstResultId.value) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/GetLastRun.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/GetLastRun.kt new file mode 100644 index 000000000..e82576913 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/GetLastRun.kt @@ -0,0 +1,69 @@ +package org.ooni.probe.domain.results + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import org.ooni.probe.data.models.ResultModel +import org.ooni.probe.data.models.ResultWithNetworkAndAggregates +import org.ooni.probe.data.models.Run +import org.ooni.probe.data.models.SettingsKey +import kotlin.time.Duration.Companion.hours + +/* + * Estimate what results belong to a single run. The criteria are: + * - No duplicated descriptors inside a single run + * - Start time has to be inside a MAX_RUN_DURATION window + * - Does not contain a result already dismissed by the user + */ +class GetLastRun( + private val getLastResults: (Int) -> Flow>, + private val getPreference: (SettingsKey) -> Flow, +) { + operator fun invoke(): Flow = + combine( + getLastResults(MAX_RESULTS_IN_RUN), + getPreference(SettingsKey.LAST_RUN_DISMISSED), + ) { lastResults, lastRunDismissed -> + val lastDoneResults = lastResults.filter { it.result.isDone } + val lastDismissedResultId = (lastRunDismissed as? Long)?.let(ResultModel::Id) + val lastRunResults = lastDoneResults.getLastRunResults(lastDismissedResultId) + if (lastRunResults.isEmpty()) { + null + } else { + Run(results = lastRunResults) + } + } + + private fun List.getLastRunResults( + lastDismissedResultId: ResultModel.Id?, + ): List { + (1..size).forEach { index -> + val list = take(index) + if ( + list.resultsExceedMaxRunDuration() || + list.hasDuplicatedDescriptors() || + list.any { it.result.id == lastDismissedResultId } + ) { + return take(index - 1) + } + } + return this + } + + private fun List.resultsExceedMaxRunDuration(): Boolean { + if (size <= 1) return false + val firstStartTime = first().result.startTime.toInstant(TimeZone.UTC) + val lastStartTime = last().result.startTime.toInstant(TimeZone.UTC) + return firstStartTime - lastStartTime > MAX_RUN_DURATION + } + + private fun List.hasDuplicatedDescriptors() = + groupBy { it.result.descriptorKey?.id ?: it.result.descriptorName } + .any { it.value.size > 1 } + + companion object Companion { + private const val MAX_RESULTS_IN_RUN = 50 + private val MAX_RUN_DURATION = 1.hours + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetResult.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/GetResult.kt similarity index 97% rename from composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetResult.kt rename to composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/GetResult.kt index e27912cd7..d967d5d81 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetResult.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/GetResult.kt @@ -1,4 +1,4 @@ -package org.ooni.probe.domain +package org.ooni.probe.domain.results import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetResults.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/GetResults.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetResults.kt rename to composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/GetResults.kt index 836a72cce..184074c1a 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetResults.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/results/GetResults.kt @@ -1,4 +1,4 @@ -package org.ooni.probe.domain +package org.ooni.probe.domain.results import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt new file mode 100644 index 000000000..b9ff4ed7f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt @@ -0,0 +1,75 @@ +package org.ooni.probe.ui.dashboard + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.dp + +@Composable +fun DashboardCard( + title: @Composable RowScope.() -> Unit, + content: @Composable () -> Unit, + modifier: Modifier = Modifier, + startActions: @Composable () -> Unit = {}, + endActions: @Composable () -> Unit = {}, + icon: Painter? = null, +) { + ElevatedCard( + colors = CardDefaults.elevatedCardColors( + disabledContainerColor = MaterialTheme.colorScheme.surface, + disabledContentColor = MaterialTheme.colorScheme.onSurface, + ), + modifier = modifier.padding(16.dp), + onClick = {}, + enabled = false, + ) { + Box { + icon?.let { + Icon( + icon, + contentDescription = null, + tint = LocalContentColor.current.copy(alpha = 0.075f), + modifier = Modifier.size(88.dp).align(Alignment.TopEnd).padding(top = 4.dp), + ) + } + Column { + Row( + verticalAlignment = Alignment.Bottom, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 16.dp, bottom = 4.dp), + ) { + title() + } + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + content() + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(top = 0.dp) + .padding(horizontal = 8.dp), + ) { + startActions() + Spacer(Modifier.weight(1f)) + endActions() + } + } + } + } +} 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 34116a466..45d97f434 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 @@ -4,6 +4,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues @@ -16,9 +17,11 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -26,28 +29,40 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.LifecycleResumeEffect import ooniprobe.composeapp.generated.resources.Dashboard_AutoRun_Disabled import ooniprobe.composeapp.generated.resources.Dashboard_AutoRun_Enabled -import ooniprobe.composeapp.generated.resources.Dashboard_Overview_LatestTest -import ooniprobe.composeapp.generated.resources.Dashboard_RunV2_RunFinished +import ooniprobe.composeapp.generated.resources.Dashboard_LastResults +import ooniprobe.composeapp.generated.resources.Measurements_Failed import ooniprobe.composeapp.generated.resources.Modal_DisableVPN_Title import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.TestResults_Overview_Websites_Blocked +import ooniprobe.composeapp.generated.resources.TestResults_Overview_Websites_Tested import ooniprobe.composeapp.generated.resources.app_name import ooniprobe.composeapp.generated.resources.dashboard_arc import ooniprobe.composeapp.generated.resources.ic_auto_run +import ooniprobe.composeapp.generated.resources.ic_history +import ooniprobe.composeapp.generated.resources.ic_measurement_anomaly +import ooniprobe.composeapp.generated.resources.ic_measurement_failed import ooniprobe.composeapp.generated.resources.ic_warning +import ooniprobe.composeapp.generated.resources.ic_world import ooniprobe.composeapp.generated.resources.logo_probe +import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview +import org.ooni.probe.data.models.Run import org.ooni.probe.data.models.RunBackgroundState import org.ooni.probe.ui.shared.IgnoreBatteryOptimizationDialog import org.ooni.probe.ui.shared.TestRunErrorMessages @@ -56,7 +71,6 @@ import org.ooni.probe.ui.shared.isHeightCompact import org.ooni.probe.ui.shared.relativeDateTime import org.ooni.probe.ui.theme.AppTheme import org.ooni.probe.ui.theme.LocalCustomColors -import org.ooni.probe.ui.theme.customColors @Composable fun DashboardScreen( @@ -102,7 +116,9 @@ fun DashboardScreen( ) { RunBackgroundStateSection(state.runBackgroundState, onEvent) - AutoRunButton(isAutoRunEnabled = state.isAutoRunEnabled, onEvent) + if (state.runBackgroundState is RunBackgroundState.Idle) { + AutoRunButton(isAutoRunEnabled = state.isAutoRunEnabled, onEvent) + } } } @@ -115,33 +131,13 @@ fun DashboardScreen( .verticalScroll(scrollState) .fillMaxSize(), ) { - if (state.runBackgroundState is RunBackgroundState.Idle) { - state.runBackgroundState.lastTestAt?.let { lastTestAt -> - Text( - text = stringResource(Res.string.Dashboard_Overview_LatestTest) + " " + lastTestAt.relativeDateTime(), - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(top = 4.dp), - ) - } - if (state.runBackgroundState.justFinishedTest) { - Button( - onClick = { onEvent(DashboardViewModel.Event.SeeResultsClicked) }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.customColors.success, - contentColor = MaterialTheme.customColors.onSuccess, - ), - modifier = Modifier.padding(top = 4.dp), - ) { - Text(stringResource(Res.string.Dashboard_RunV2_RunFinished)) - } - } - } - if (state.showVpnWarning) { VpnWarning() } - // TODO: Rest of the content here + if (state.runBackgroundState is RunBackgroundState.Idle && state.lastRun != null) { + LastRun(state.lastRun, onEvent) + } } VerticalScrollbar(state = scrollState, modifier = Modifier.align(Alignment.CenterEnd)) } @@ -236,6 +232,108 @@ private fun VpnWarning() { } } +@Composable +private fun LastRun( + run: Run, + onEvent: (DashboardViewModel.Event) -> Unit, +) { + DashboardCard( + title = { + Text( + stringResource(Res.string.Dashboard_LastResults), + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Bold, + ), + ) + Text( + run.startTime.relativeDateTime(), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(start = 8.dp, bottom = 2.dp), + ) + }, + content = { + FlowRow { + ResultChip( + text = pluralStringResource( + Res.plurals.TestResults_Overview_Websites_Tested, + run.measurementCounts.tested.toInt(), + run.measurementCounts.tested, + ), + icon = Res.drawable.ic_world, + modifier = Modifier.padding(end = 2.dp), + ) + + ResultChip( + text = pluralStringResource( + Res.plurals.TestResults_Overview_Websites_Blocked, + run.measurementCounts.anomaly.toInt(), + run.measurementCounts.anomaly, + ), + icon = Res.drawable.ic_measurement_anomaly, + iconTint = LocalCustomColors.current.logWarn, + modifier = Modifier.padding(end = 2.dp), + ) + + ResultChip( + text = pluralStringResource( + Res.plurals.Measurements_Failed, + run.measurementCounts.failed.toInt(), + run.measurementCounts.failed, + ), + icon = Res.drawable.ic_measurement_failed, + iconTint = MaterialTheme.colorScheme.error, + modifier = Modifier, + ) + } + }, + startActions = { + TextButton(onClick = { onEvent(DashboardViewModel.Event.DismissResultsClicked) }) { + Text( + "Dismiss", + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.66f), + ) + } + }, + endActions = { + TextButton(onClick = { onEvent(DashboardViewModel.Event.SeeResultsClicked) }) { + Text("See results") + } + }, + icon = painterResource(Res.drawable.ic_history), + ) +} + +@Composable +fun ResultChip( + text: String, + icon: DrawableResource, + modifier: Modifier = Modifier, + iconTint: Color? = null, +) { + AssistChip( + onClick = {}, + enabled = false, + label = { Text(text = text) }, + leadingIcon = { + Icon( + painterResource(icon), + contentDescription = null, + tint = iconTint ?: LocalContentColor.current, + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + colors = AssistChipDefaults.assistChipColors( + disabledContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + disabledLabelColor = LocalContentColor.current, + disabledLeadingIconContentColor = LocalContentColor.current, + ), + border = AssistChipDefaults.assistChipBorder(true), + modifier = modifier, + ) +} + @Preview @Composable fun DashboardScreenPreview() { 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 2308a969a..35bd7d626 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 @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.update import org.ooni.probe.config.BatteryOptimization import org.ooni.probe.data.models.AutoRunParameters +import org.ooni.probe.data.models.Run import org.ooni.probe.data.models.RunBackgroundState import org.ooni.probe.data.models.TestRunError import org.ooni.probe.shared.tickerFlow @@ -28,10 +29,12 @@ class DashboardViewModel( goToRunTests: () -> Unit, goToTestSettings: () -> Unit, getFirstRun: () -> Flow, - observeRunBackgroundState: Flow, - observeTestRunErrors: Flow, + observeRunBackgroundState: () -> Flow, + observeTestRunErrors: () -> Flow, shouldShowVpnWarning: suspend () -> Boolean, getAutoRunSettings: () -> Flow, + getLastRun: () -> Flow, + dismissLastRun: suspend () -> Unit, batteryOptimization: BatteryOptimization, ) : ViewModel() { private val events = MutableSharedFlow(extraBufferCapacity = 1) @@ -65,16 +68,21 @@ class DashboardViewModel( } }.launchIn(viewModelScope) - observeRunBackgroundState + observeRunBackgroundState() .onEach { testState -> _state.update { it.copy(runBackgroundState = testState) } }.launchIn(viewModelScope) - observeTestRunErrors + observeTestRunErrors() .onEach { error -> _state.update { it.copy(testRunErrors = it.testRunErrors + error) } }.launchIn(viewModelScope) + getLastRun() + .onEach { run -> + _state.update { it.copy(lastRun = run) } + }.launchIn(viewModelScope) + events .filterIsInstance() .onEach { goToRunTests() } @@ -95,6 +103,11 @@ class DashboardViewModel( .onEach { goToResults() } .launchIn(viewModelScope) + events + .filterIsInstance() + .onEach { dismissLastRun() } + .launchIn(viewModelScope) + events .filterIsInstance() .onEach { event -> @@ -134,10 +147,11 @@ class DashboardViewModel( } data class State( - val runBackgroundState: RunBackgroundState = RunBackgroundState.Idle(), + val runBackgroundState: RunBackgroundState = RunBackgroundState.Idle, val isAutoRunEnabled: Boolean = false, val testRunErrors: List = emptyList(), val showVpnWarning: Boolean = false, + val lastRun: Run? = null, val showIgnoreBatteryOptimizationNotice: Boolean = false, ) @@ -154,6 +168,8 @@ class DashboardViewModel( data object SeeResultsClicked : Event + data object DismissResultsClicked : Event + data class ErrorDisplayed( val error: TestRunError, ) : Event diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/RunBackgroundStateSection.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/RunBackgroundStateSection.kt index 068380271..12745a05f 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/RunBackgroundStateSection.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/RunBackgroundStateSection.kt @@ -228,7 +228,7 @@ private fun RunBackgroundState.RunningTests.testIcon() = fun RunBackgroundIdlePreview() { AppTheme { Idle( - state = RunBackgroundState.Idle(), + state = RunBackgroundState.Idle, onEvent = {}, ) } 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 d4c67185b..04d261e4f 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 @@ -95,10 +95,6 @@ fun Navigation( composable { val viewModel = viewModel { dependencies.descriptorsViewModel( - goToOnboarding = { navController.goBackAndNavigate(Screen.Onboarding) }, - goToResults = { navController.navigateToMainScreen(Screen.Results) }, - goToRunningTest = { navController.safeNavigate(Screen.RunningTest) }, - goToRunTests = { navController.safeNavigate(Screen.RunTests) }, goToDescriptor = { descriptorKey -> navController.safeNavigate(Screen.Descriptor(descriptorKey)) }, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt index 049844ffa..f1390f416 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TriStateCheckbox import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -363,10 +362,6 @@ fun ResultsScreen( }, ) } - - LaunchedEffect(Unit) { - onEvent(ResultsViewModel.Event.Start) - } } @Composable diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsViewModel.kt index f2b6195ec..7484942f4 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsViewModel.kt @@ -28,7 +28,6 @@ class ResultsViewModel( getDescriptors: () -> Flow>, getNetworks: () -> Flow>, deleteResultsByFilter: suspend (ResultFilter) -> Unit, - markJustFinishedTestAsSeen: () -> Unit, markAsViewed: suspend (ResultFilter) -> Unit, deleteResults: suspend (List) -> Unit = {}, ) : ViewModel() { @@ -76,11 +75,6 @@ class ResultsViewModel( .onEach { networks -> _state.update { it.copy(networks = networks) } } .launchIn(viewModelScope) - events - .filterIsInstance() - .onEach { markJustFinishedTestAsSeen() } - .launchIn(viewModelScope) - events .filterIsInstance() .onEach { goToResult(it.result.idOrThrow) } @@ -221,8 +215,6 @@ class ResultsViewModel( ) sealed interface Event { - data object Start : Event - data class ResultClick( val result: ResultListItem, ) : Event diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/running/RunningViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/running/RunningViewModel.kt index 5acffcb2d..6f80deb69 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/running/RunningViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/running/RunningViewModel.kt @@ -43,13 +43,8 @@ class RunningViewModel( observeRunBackgroundState .filterIsInstance() .take(1) - .onEach { testRunState -> - if (testRunState.justFinishedTest) { - goToResults() - } else { - onBack() - } - }.launchIn(viewModelScope) + .onEach { goToResults() } + .launchIn(viewModelScope) observeTestRunErrors .onEach { error -> diff --git a/composeApp/src/commonMain/sqldelight/migrations/14.sqm b/composeApp/src/commonMain/sqldelight/migrations/14.sqm new file mode 100644 index 000000000..256d324ad --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/migrations/14.sqm @@ -0,0 +1,44 @@ +CREATE VIEW ResultWithNetworkAndAggregates AS +SELECT *, + notUploadedMeasurements == 0 AS allMeasurementsUploaded, + uploadFailCount > 0 AS anyMeasurementUploadFailed +FROM ( + SELECT + MAX(Result.id) AS id, + MAX(Result.descriptor_name) AS descriptor_name, + MAX(Result.start_time) AS start_time, + MAX(Result.is_viewed) AS is_viewed, + MAX(Result.is_done) AS is_done, + MAX(Result.data_usage_up) AS data_usage_up, + MAX(Result.data_usage_down) AS data_usage_down, + MAX(Result.failure_msg) AS failure_msg, + MAX(Result.task_origin) AS task_origin, + MAX(Result.network_id) AS network_id, + MAX(Result.descriptor_runId) AS descriptor_runId, + MAX(Result.descriptor_revision) AS descriptor_revision, + MAX(Network.id) AS network_id_inner, + MAX(Network.network_name) AS network_name, + MAX(Network.asn) AS asn, + MAX(Network.country_code) AS country_code, + MAX(Network.network_type) AS network_type, + COUNT(Measurement.id) AS measurementsCount, + SUM( + CASE WHEN Measurement.is_done = 1 + AND (Measurement.is_uploaded = 0 OR Measurement.report_id IS NULL) + AND Measurement.is_upload_failed = 1 + THEN 1 ELSE 0 END + ) AS uploadFailCount, + SUM( + CASE WHEN Measurement.is_done = 1 + AND (Measurement.is_uploaded = 0 OR Measurement.report_id IS NULL) + THEN 1 ELSE 0 END + ) AS notUploadedMeasurements, + SUM(CASE WHEN Measurement.is_done = 1 THEN 1 ELSE 0 END) AS doneMeasurementsCount, + SUM(CASE WHEN Measurement.is_failed = 1 THEN 1 ELSE 0 END) AS failedMeasurementsCount, + SUM(CASE WHEN Measurement.is_anomaly = 1 THEN 1 ELSE 0 END) AS anomalyMeasurementsCount + FROM Result + LEFT JOIN Network ON Result.network_id = Network.id + LEFT JOIN Measurement ON Measurement.result_id = Result.id + GROUP BY Result.id + ORDER BY Result.start_time DESC +); 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 55e9e30b6..2158f86df 100644 --- a/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Result.sq +++ b/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Result.sq @@ -19,6 +19,51 @@ CREATE INDEX idx_result_start_time ON Result (start_time); CREATE INDEX idx_result_descriptor_name ON Result (descriptor_name); CREATE INDEX idx_result_task_origin ON Result (task_origin); +CREATE VIEW ResultWithNetworkAndAggregates AS +SELECT *, + notUploadedMeasurements == 0 AS allMeasurementsUploaded, + uploadFailCount > 0 AS anyMeasurementUploadFailed +FROM ( + SELECT + MAX(Result.id) AS id, + MAX(Result.descriptor_name) AS descriptor_name, + MAX(Result.start_time) AS start_time, + MAX(Result.is_viewed) AS is_viewed, + MAX(Result.is_done) AS is_done, + MAX(Result.data_usage_up) AS data_usage_up, + MAX(Result.data_usage_down) AS data_usage_down, + MAX(Result.failure_msg) AS failure_msg, + MAX(Result.task_origin) AS task_origin, + MAX(Result.network_id) AS network_id, + MAX(Result.descriptor_runId) AS descriptor_runId, + MAX(Result.descriptor_revision) AS descriptor_revision, + MAX(Network.id) AS network_id_inner, + MAX(Network.network_name) AS network_name, + MAX(Network.asn) AS asn, + MAX(Network.country_code) AS country_code, + MAX(Network.network_type) AS network_type, + COUNT(Measurement.id) AS measurementsCount, + SUM( + CASE WHEN Measurement.is_done = 1 + AND (Measurement.is_uploaded = 0 OR Measurement.report_id IS NULL) + AND Measurement.is_upload_failed = 1 + THEN 1 ELSE 0 END + ) AS uploadFailCount, + SUM( + CASE WHEN Measurement.is_done = 1 + AND (Measurement.is_uploaded = 0 OR Measurement.report_id IS NULL) + THEN 1 ELSE 0 END + ) AS notUploadedMeasurements, + SUM(CASE WHEN Measurement.is_done = 1 THEN 1 ELSE 0 END) AS doneMeasurementsCount, + SUM(CASE WHEN Measurement.is_failed = 1 THEN 1 ELSE 0 END) AS failedMeasurementsCount, + SUM(CASE WHEN Measurement.is_anomaly = 1 THEN 1 ELSE 0 END) AS anomalyMeasurementsCount + FROM Result + LEFT JOIN Network ON Result.network_id = Network.id + LEFT JOIN Measurement ON Measurement.result_id = Result.id + GROUP BY Result.id + ORDER BY Result.start_time DESC +); + insertOrReplace: INSERT OR REPLACE INTO Result ( id, @@ -86,60 +131,19 @@ selectLastInsertedRowId: SELECT last_insert_rowid(); selectAllWithNetwork: -SELECT *, - notUploadedMeasurements == 0 AS allMeasurementsUploaded, - uploadFailCount > 0 AS anyMeasurementUploadFailed -FROM ( - SELECT - MAX(Result.id) AS id, - MAX(Result.descriptor_name) AS descriptor_name, - MAX(Result.start_time) AS start_time, - MAX(Result.is_viewed) AS is_viewed, - MAX(Result.is_done) AS is_done, - MAX(Result.data_usage_up) AS data_usage_up, - MAX(Result.data_usage_down) AS data_usage_down, - MAX(Result.failure_msg) AS failure_msg, - MAX(Result.task_origin) AS task_origin, - MAX(Result.network_id) AS network_id, - MAX(Result.descriptor_runId) AS descriptor_runId, - MAX(Result.descriptor_revision) AS descriptor_revision, - MAX(Network.id) AS network_id_inner, - MAX(Network.network_name) AS network_name, - MAX(Network.asn) AS asn, - MAX(Network.country_code) AS country_code, - MAX(Network.network_type) AS network_type, - COUNT(Measurement.id) AS measurementsCount, - SUM( - CASE WHEN Measurement.is_done = 1 - AND (Measurement.is_uploaded = 0 OR Measurement.report_id IS NULL) - AND Measurement.is_upload_failed = 1 - THEN 1 ELSE 0 END - ) AS uploadFailCount, - SUM( - CASE WHEN Measurement.is_done = 1 - AND (Measurement.is_uploaded = 0 OR Measurement.report_id IS NULL) - THEN 1 ELSE 0 END - ) AS notUploadedMeasurements, - SUM(CASE WHEN Measurement.is_done = 1 THEN 1 ELSE 0 END) AS doneMeasurementsCount, - SUM(CASE WHEN Measurement.is_failed = 1 THEN 1 ELSE 0 END) AS failedMeasurementsCount, - SUM(CASE WHEN Measurement.is_anomaly = 1 THEN 1 ELSE 0 END) AS anomalyMeasurementsCount - FROM Result - LEFT JOIN Network ON Result.network_id = Network.id - LEFT JOIN Measurement ON Measurement.result_id = Result.id - WHERE ( - :filterByDescriptors = 0 OR - Result.descriptor_name IN :descriptorsKeys OR Result.descriptor_runId IN :descriptorsKeys - ) AND ( - :filterByNetworks = 0 OR Result.network_id IN :networkIds - ) AND ( - :filterByTaskOrigin = 0 OR Result.task_origin = :taskOrigin - ) AND ( - Result.start_time >= :startFrom AND Result.start_time <= :startUntil - ) - GROUP BY Result.id - ORDER BY Result.start_time DESC - LIMIT :limit -); +SELECT * +FROM ResultWithNetworkAndAggregates +WHERE ( + :filterByDescriptors = 0 OR + descriptor_name IN :descriptorsKeys OR descriptor_runId IN :descriptorsKeys +) AND ( + :filterByNetworks = 0 OR network_id IN :networkIds +) AND ( + :filterByTaskOrigin = 0 OR task_origin = :taskOrigin +) AND ( + start_time >= :startFrom AND start_time <= :startUntil +) +LIMIT :limit; selectByIdWithNetwork: SELECT Result.*, Network.* @@ -153,6 +157,11 @@ SELECT * FROM Result ORDER BY start_time DESC LIMIT 1; +selectLast: +SELECT * FROM ResultWithNetworkAndAggregates +ORDER BY start_time DESC +LIMIT :limit; + selectLastDoneByDescriptor: SELECT Result.id FROM Result WHERE (Result.descriptor_name = ?1 OR Result.descriptor_runId = ?1) AND Result.is_done = 1 diff --git a/composeApp/src/commonTest/kotlin/org/ooni/probe/background/RunBackgroundTaskTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/background/RunBackgroundTaskTest.kt index a725659a3..69fbd0fdb 100644 --- a/composeApp/src/commonTest/kotlin/org/ooni/probe/background/RunBackgroundTaskTest.kt +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/background/RunBackgroundTaskTest.kt @@ -23,14 +23,14 @@ class RunBackgroundTaskTest { fun skipIfFailedAutoRunConstraints() = runTest { var wasRunDescriptorsCalled = false - val state = MutableStateFlow(RunBackgroundState.Idle()) + val state = MutableStateFlow(RunBackgroundState.Idle) val subject = buildSubject( checkAutoRunConstraints = { false }, runDescriptors = { wasRunDescriptorsCalled = true state.value = RunBackgroundState.RunningTests() delay(100) - state.value = RunBackgroundState.Idle() + state.value = RunBackgroundState.Idle }, ) @@ -52,7 +52,7 @@ class RunBackgroundTaskTest { }, runDescriptors: suspend (RunSpecification) -> Unit = {}, setRunBackgroundState: ((RunBackgroundState) -> RunBackgroundState) -> Unit = {}, - getRunBackgroundState: () -> Flow = { flowOf(RunBackgroundState.Idle()) }, + getRunBackgroundState: () -> Flow = { flowOf(RunBackgroundState.Idle) }, addRunCancelListener: (() -> Unit) -> CancelListenerCallback = { CancelListenerCallback {} }, getLatestResult: () -> Flow = { flowOf(null) }, ) = RunBackgroundTask( diff --git a/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/results/ResultsScreenTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/results/ResultsScreenTest.kt index 8cd04101f..48b1ee2a5 100644 --- a/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/results/ResultsScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/results/ResultsScreenTest.kt @@ -20,20 +20,6 @@ import kotlin.test.Test import kotlin.test.assertEquals class ResultsScreenTest { - @Test - fun start() = - runComposeUiTest { - val events = mutableListOf() - setContent { - ResultsScreen( - state = ResultsViewModel.State(results = emptyMap(), isLoading = true), - onEvent = events::add, - ) - } - - assertEquals(ResultsViewModel.Event.Start, events.last()) - } - @Test fun showResults() = runComposeUiTest { diff --git a/composeApp/src/desktopMain/kotlin/org/ooni/probe/Main.kt b/composeApp/src/desktopMain/kotlin/org/ooni/probe/Main.kt index 5567a410c..57532f0a7 100644 --- a/composeApp/src/desktopMain/kotlin/org/ooni/probe/Main.kt +++ b/composeApp/src/desktopMain/kotlin/org/ooni/probe/Main.kt @@ -89,7 +89,7 @@ fun main(args: Array) { val deepLink by deepLinkFlow.collectAsState(null) val runBackgroundState by dependencies.runBackgroundStateManager .observeState() - .collectAsState(RunBackgroundState.Idle()) + .collectAsState(RunBackgroundState.Idle) // Observe update state for UI val updateState by updateController.state.collectAsState(UpdateState.IDLE) @@ -194,7 +194,7 @@ private fun trayIcon(): DrawableResource { (dependencies.platformInfo.platform as? Platform.Desktop)?.os == DesktopOS.Windows val runBackgroundState by dependencies.runBackgroundStateManager .observeState() - .collectAsState(RunBackgroundState.Idle()) + .collectAsState(RunBackgroundState.Idle) val isRunning = runBackgroundState !is RunBackgroundState.Idle return when { isDarkTheme && isWindows && isRunning -> Res.drawable.tray_icon_windows_dark_running From d13f0a178238b97ba20358970101615a7fcec8bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Wed, 15 Oct 2025 18:54:14 +0100 Subject: [PATCH 04/21] Tests moved notice --- .../values/strings-common.xml | 4 ++ .../org/ooni/probe/data/models/SettingsKey.kt | 1 + .../kotlin/org/ooni/probe/di/Dependencies.kt | 4 ++ .../ooni/probe/domain/BootstrapPreferences.kt | 1 + .../ooni/probe/ui/dashboard/DashboardCard.kt | 2 +- .../probe/ui/dashboard/DashboardScreen.kt | 48 ++++++++++++++++++- .../probe/ui/dashboard/DashboardViewModel.kt | 24 ++++++++++ .../ooni/probe/ui/navigation/Navigation.kt | 1 + 8 files changed, 82 insertions(+), 3 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index e758fe25a..1c9380e77 100644 --- a/composeApp/src/commonMain/composeResources/values/strings-common.xml +++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml @@ -18,6 +18,10 @@ enabled]]> disabled]]> + Tests + Your tests moved to a new tab. You can reach them throw the navigation bar below. + See tests + Run tests Select the tests to run Select all tests diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/SettingsKey.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/SettingsKey.kt index 46b969b0c..b3d4670b7 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/SettingsKey.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/SettingsKey.kt @@ -68,6 +68,7 @@ enum class SettingsKey( CHOSEN_WEBSITES("chosen_websites"), DESCRIPTOR_SECTIONS_COLLAPSED("descriptor_sections_collapsed"), LAST_RUN_DISMISSED("last_run_dismissed"), + TESTS_MOVED_NOTICE("tests_moved_notice"), ROUTE("route"), 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 1a9d01468..9071f97c0 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -575,12 +575,14 @@ class Dependencies( goToResults: () -> Unit, goToRunningTest: () -> Unit, goToRunTests: () -> Unit, + goToTests: () -> Unit, goToTestSettings: () -> Unit, ) = DashboardViewModel( goToOnboarding = goToOnboarding, goToResults = goToResults, goToRunningTest = goToRunningTest, goToRunTests = goToRunTests, + goToTests = goToTests, goToTestSettings = goToTestSettings, getFirstRun = getFirstRun::invoke, observeRunBackgroundState = runBackgroundStateManager::observeState, @@ -589,6 +591,8 @@ class Dependencies( getAutoRunSettings = getAutoRunSettings::invoke, getLastRun = getLastRun::invoke, dismissLastRun = dismissLastRun::invoke, + getPreference = preferenceRepository::getValueByKey, + setPreference = preferenceRepository::setValueByKey, batteryOptimization = batteryOptimization, ) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/BootstrapPreferences.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/BootstrapPreferences.kt index f26fef2de..31aabffc4 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/BootstrapPreferences.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/BootstrapPreferences.kt @@ -34,6 +34,7 @@ class BootstrapPreferences( SettingsKey.DELETE_OLD_RESULTS to true, SettingsKey.DELETE_OLD_RESULTS_THRESHOLD to DeleteOldResults.DELETE_OLD_RESULTS_THRESHOLD_DEFAULT_IN_MONTHS, + SettingsKey.TESTS_MOVED_NOTICE to true, ) + organizationPreferenceDefaults(), ) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt index b9ff4ed7f..398d047cd 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt @@ -33,7 +33,7 @@ fun DashboardCard( disabledContainerColor = MaterialTheme.colorScheme.surface, disabledContentColor = MaterialTheme.colorScheme.onSurface, ), - modifier = modifier.padding(16.dp), + modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp), onClick = {}, enabled = false, ) { 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 45d97f434..b6fe7dcc1 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 @@ -40,9 +40,14 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.LifecycleResumeEffect +import ooniprobe.composeapp.generated.resources.Common_Dismiss import ooniprobe.composeapp.generated.resources.Dashboard_AutoRun_Disabled import ooniprobe.composeapp.generated.resources.Dashboard_AutoRun_Enabled import ooniprobe.composeapp.generated.resources.Dashboard_LastResults +import ooniprobe.composeapp.generated.resources.Dashboard_LastResults_SeeResults +import ooniprobe.composeapp.generated.resources.Dashboard_TestsMoved_Action +import ooniprobe.composeapp.generated.resources.Dashboard_TestsMoved_Description +import ooniprobe.composeapp.generated.resources.Dashboard_TestsMoved_Title import ooniprobe.composeapp.generated.resources.Measurements_Failed import ooniprobe.composeapp.generated.resources.Modal_DisableVPN_Title import ooniprobe.composeapp.generated.resources.Res @@ -54,6 +59,7 @@ import ooniprobe.composeapp.generated.resources.ic_auto_run import ooniprobe.composeapp.generated.resources.ic_history import ooniprobe.composeapp.generated.resources.ic_measurement_anomaly import ooniprobe.composeapp.generated.resources.ic_measurement_failed +import ooniprobe.composeapp.generated.resources.ic_tests import ooniprobe.composeapp.generated.resources.ic_warning import ooniprobe.composeapp.generated.resources.ic_world import ooniprobe.composeapp.generated.resources.logo_probe @@ -122,6 +128,7 @@ fun DashboardScreen( } } + // Scrollable Content Box(Modifier.fillMaxSize()) { val scrollState = rememberScrollState() Column( @@ -138,6 +145,10 @@ fun DashboardScreen( if (state.runBackgroundState is RunBackgroundState.Idle && state.lastRun != null) { LastRun(state.lastRun, onEvent) } + + if (state.showTestsMovedNotice) { + TestsMoved(onEvent) + } } VerticalScrollbar(state = scrollState, modifier = Modifier.align(Alignment.CenterEnd)) } @@ -291,14 +302,14 @@ private fun LastRun( startActions = { TextButton(onClick = { onEvent(DashboardViewModel.Event.DismissResultsClicked) }) { Text( - "Dismiss", + stringResource(Res.string.Common_Dismiss), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.66f), ) } }, endActions = { TextButton(onClick = { onEvent(DashboardViewModel.Event.SeeResultsClicked) }) { - Text("See results") + Text(stringResource(Res.string.Dashboard_LastResults_SeeResults)) } }, icon = painterResource(Res.drawable.ic_history), @@ -334,6 +345,39 @@ fun ResultChip( ) } +@Composable +private fun TestsMoved(onEvent: (DashboardViewModel.Event) -> Unit) { + DashboardCard( + title = { + Text( + stringResource(Res.string.Dashboard_TestsMoved_Title), + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Bold, + ), + ) + }, + content = { + Text(stringResource(Res.string.Dashboard_TestsMoved_Description)) + }, + startActions = { + TextButton(onClick = { onEvent(DashboardViewModel.Event.DismissTestsMovedClicked) }) { + Text( + stringResource(Res.string.Common_Dismiss), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.66f), + ) + } + }, + endActions = { + TextButton(onClick = { onEvent(DashboardViewModel.Event.SeeTestsClicked) }) { + Text(stringResource(Res.string.Dashboard_TestsMoved_Action)) + } + }, + icon = painterResource(Res.drawable.ic_tests), + ) +} + @Preview @Composable fun DashboardScreenPreview() { 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 35bd7d626..79a5bca0c 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 @@ -18,6 +18,7 @@ import org.ooni.probe.config.BatteryOptimization import org.ooni.probe.data.models.AutoRunParameters import org.ooni.probe.data.models.Run import org.ooni.probe.data.models.RunBackgroundState +import org.ooni.probe.data.models.SettingsKey import org.ooni.probe.data.models.TestRunError import org.ooni.probe.shared.tickerFlow import kotlin.time.Duration.Companion.seconds @@ -27,6 +28,7 @@ class DashboardViewModel( goToResults: () -> Unit, goToRunningTest: () -> Unit, goToRunTests: () -> Unit, + goToTests: () -> Unit, goToTestSettings: () -> Unit, getFirstRun: () -> Flow, observeRunBackgroundState: () -> Flow, @@ -35,6 +37,8 @@ class DashboardViewModel( getAutoRunSettings: () -> Flow, getLastRun: () -> Flow, dismissLastRun: suspend () -> Unit, + getPreference: (SettingsKey) -> Flow, + setPreference: suspend (SettingsKey, Any) -> Unit, batteryOptimization: BatteryOptimization, ) : ViewModel() { private val events = MutableSharedFlow(extraBufferCapacity = 1) @@ -83,6 +87,11 @@ class DashboardViewModel( _state.update { it.copy(lastRun = run) } }.launchIn(viewModelScope) + getPreference(SettingsKey.TESTS_MOVED_NOTICE) + .onEach { preference -> + _state.update { it.copy(showTestsMovedNotice = preference != true) } + }.launchIn(viewModelScope) + events .filterIsInstance() .onEach { goToRunTests() } @@ -108,6 +117,16 @@ class DashboardViewModel( .onEach { dismissLastRun() } .launchIn(viewModelScope) + events + .filterIsInstance() + .onEach { goToTests() } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { setPreference(SettingsKey.TESTS_MOVED_NOTICE, true) } + .launchIn(viewModelScope) + events .filterIsInstance() .onEach { event -> @@ -153,6 +172,7 @@ class DashboardViewModel( val showVpnWarning: Boolean = false, val lastRun: Run? = null, val showIgnoreBatteryOptimizationNotice: Boolean = false, + val showTestsMovedNotice: Boolean = false, ) sealed interface Event { @@ -170,6 +190,10 @@ class DashboardViewModel( data object DismissResultsClicked : Event + data object SeeTestsClicked : Event + + data object DismissTestsMovedClicked : Event + data class ErrorDisplayed( val error: TestRunError, ) : 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 04d261e4f..b0108f1eb 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 @@ -81,6 +81,7 @@ fun Navigation( goToResults = { navController.navigateToMainScreen(Screen.Results) }, goToRunningTest = { navController.safeNavigate(Screen.RunningTest) }, goToRunTests = { navController.safeNavigate(Screen.RunTests) }, + goToTests = { navController.navigateToMainScreen(Screen.Descriptors) }, goToTestSettings = { navController.safeNavigate( Screen.SettingsCategory(PreferenceCategoryKey.TEST_OPTIONS.value), From 5bed6e21bb602a3b140e59cb19ab60002886cc9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Mon, 20 Oct 2025 18:48:41 +0100 Subject: [PATCH 05/21] Measurement stats --- .../values/strings-common.xml | 14 +++ .../probe/data/models/MeasurementStats.kt | 10 ++ .../probe/data/models/TestKeysWithResultId.kt | 19 ++-- .../repositories/MeasurementRepository.kt | 7 ++ .../data/repositories/NetworkRepository.kt | 14 +++ .../kotlin/org/ooni/probe/di/Dependencies.kt | 9 ++ .../org/ooni/probe/domain/GetSettings.kt | 2 +- .../kotlin/org/ooni/probe/domain/GetStats.kt | 42 ++++++++ .../org/ooni/probe/shared/DateTimeExt.kt | 2 + .../NumberExt.kt} | 11 ++- .../ooni/probe/ui/dashboard/DashboardCard.kt | 5 +- .../probe/ui/dashboard/DashboardScreen.kt | 97 ++++++++++++++++--- .../probe/ui/dashboard/DashboardViewModel.kt | 8 ++ .../org/ooni/probe/ui/result/ResultScreen.kt | 2 +- .../ooni/probe/ui/results/ResultsScreen.kt | 2 +- .../org/ooni/probe/ui/theme/CustomType.kt | 23 +++++ .../org/ooni/probe/data/Measurement.sq | 4 + .../sqldelight/org/ooni/probe/data/Network.sq | 7 ++ 18 files changed, 252 insertions(+), 26 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/MeasurementStats.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetStats.kt rename composeApp/src/commonMain/kotlin/org/ooni/probe/{ui/shared/DataUsageFormats.kt => shared/NumberExt.kt} (62%) create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/theme/CustomType.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index 1c9380e77..5e68e96c8 100644 --- a/composeApp/src/commonMain/composeResources/values/strings-common.xml +++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml @@ -31,6 +31,17 @@ Run %1$d tests + Your Measurements + + Network + Networks + + + Country + Countries + + Start running tests to see your statistics here. + OONI Tests OONI Run Links Created by %1$s on %2$s @@ -450,6 +461,9 @@ Today Yesterday + Week + Month + Total %1$s ago %1$d second diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/MeasurementStats.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/MeasurementStats.kt new file mode 100644 index 000000000..cbf691c84 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/MeasurementStats.kt @@ -0,0 +1,10 @@ +package org.ooni.probe.data.models + +data class MeasurementStats( + val measurementsToday: Long, + val measurementsWeek: Long, + val measurementsMonth: Long, + val measurementsTotal: Long, + val networks: Long, + val countries: Long, +) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestKeysWithResultId.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestKeysWithResultId.kt index 54004514b..a4dc3e240 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestKeysWithResultId.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestKeysWithResultId.kt @@ -19,7 +19,8 @@ import ooniprobe.composeapp.generated.resources.r720p_ext import org.jetbrains.compose.resources.StringResource import org.ooni.engine.models.TestKeys import org.ooni.engine.models.TestType -import org.ooni.probe.ui.shared.format +import org.ooni.probe.shared.format +import org.ooni.probe.shared.withFractionalDigits data class TestKeysWithResultId( val id: MeasurementModel.Id, @@ -36,7 +37,7 @@ fun List.videoQuality() = fun List.uploadSpeed() = this.firstOrNull { TestType.Ndt.name == it.testName }?.testKeys?.let { testKey -> return@let testKey.summary?.upload?.let { - val upload = setFractionalDigits(getScaledValue(it)) + val upload = getScaledValue(it).withFractionalDigits() val unit = getUnit(it) upload to unit } @@ -45,7 +46,7 @@ fun List.uploadSpeed() = fun List.downloadSpeed() = this.firstOrNull { TestType.Ndt.name == it.testName }?.testKeys?.let { testKey -> return@let testKey.summary?.download?.let { - val download = setFractionalDigits(getScaledValue(it)) + val download = getScaledValue(it).withFractionalDigits() val unit = getUnit(it) download to unit } @@ -59,11 +60,11 @@ fun List.ping() = ?.ping ?.format(1) -fun TestKeys.getVideoQuality(extended: Boolean): StringResource { - return simple?.medianBitrate?.let { - return minimumBitrateForVideo(it, extended) - } ?: Res.string.TestResults_NotAvailable -} +fun TestKeys.getVideoQuality(extended: Boolean): StringResource = + simple + ?.medianBitrate + ?.let { minimumBitrateForVideo(it, extended) } + ?: Res.string.TestResults_NotAvailable private fun minimumBitrateForVideo( videoQuality: Double, @@ -108,8 +109,6 @@ fun getScaledValue(value: Double): Double = value / 1000 * 1000 } -fun setFractionalDigits(value: Double): String = if (value < 10) value.format(1) else value.format(2) - fun getUnit(value: Double): StringResource { // We assume there is no Tbit/s (for now!) return if (value < 1000) { diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/MeasurementRepository.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/MeasurementRepository.kt index ad3869834..98b8d2773 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/MeasurementRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/MeasurementRepository.kt @@ -7,6 +7,7 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext +import kotlinx.datetime.LocalDateTime import kotlinx.serialization.json.Json import org.ooni.engine.models.TestKeys import org.ooni.engine.models.TestType @@ -94,6 +95,12 @@ class MeasurementRepository( .mapToOne(backgroundContext) .map { it.toModel() } + fun countFromStartTime(startTime: LocalDateTime): Flow = + database.measurementQueries + .countFromStartTime(startTime.toEpoch()) + .asFlow() + .mapToOne(backgroundContext) + suspend fun createOrUpdate(model: MeasurementModel): MeasurementModel.Id = withContext(backgroundContext) { database.transactionWithResult { diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/NetworkRepository.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/NetworkRepository.kt index 076b7968e..7697ef44f 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/NetworkRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/NetworkRepository.kt @@ -2,6 +2,8 @@ package org.ooni.probe.data.repositories import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList +import app.cash.sqldelight.coroutines.mapToOne +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import org.ooni.engine.models.NetworkType @@ -55,6 +57,18 @@ class NetworkRepository( .mapToList(backgroundContext) .map { list -> list.map { it.toModel() } } + fun countAsns(): Flow = + database.networkQueries + .countAsns() + .asFlow() + .mapToOne(backgroundContext) + + fun countCountries(): Flow = + database.networkQueries + .countCountries() + .asFlow() + .mapToOne(backgroundContext) + suspend fun deleteWithoutResult() = withContext(backgroundContext) { database.networkQueries.deleteWithoutResult() 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 9071f97c0..dfa173aac 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -59,6 +59,7 @@ import org.ooni.probe.domain.GetFirstRun import org.ooni.probe.domain.GetLastResultOfDescriptor import org.ooni.probe.domain.GetMeasurementsNotUploaded import org.ooni.probe.domain.GetSettings +import org.ooni.probe.domain.GetStats import org.ooni.probe.domain.GetStorageUsed import org.ooni.probe.domain.ObserveAndConfigureAutoRun import org.ooni.probe.domain.ObserveAndConfigureAutoUpdate @@ -386,6 +387,13 @@ class Dependencies( cleanupLegacyDirectories = cleanupLegacyDirectories, ) } + private val getStats by lazy { + GetStats( + countMeasurementsFromStartTime = measurementRepository::countFromStartTime, + countNetworkAsns = networkRepository::countAsns, + countNetworkCountries = networkRepository::countCountries, + ) + } @VisibleForTesting val getTestDescriptors by lazy { @@ -593,6 +601,7 @@ class Dependencies( dismissLastRun = dismissLastRun::invoke, getPreference = preferenceRepository::getValueByKey, setPreference = preferenceRepository::setValueByKey, + getStats = getStats::invoke, batteryOptimization = batteryOptimization, ) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetSettings.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetSettings.kt index 82291402d..8e0df5a42 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetSettings.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetSettings.kt @@ -69,10 +69,10 @@ import org.ooni.probe.data.models.SettingsItem import org.ooni.probe.data.models.SettingsKey import org.ooni.probe.data.repositories.PreferenceRepository import org.ooni.probe.domain.results.DeleteOldResults +import org.ooni.probe.shared.formatDataUsage import org.ooni.probe.ui.settings.category.SettingsDescription import org.ooni.probe.ui.settings.donate.DONATE_SETTINGS_ITEM import org.ooni.probe.ui.shared.format -import org.ooni.probe.ui.shared.formatDataUsage import kotlin.time.Duration.Companion.seconds class GetSettings( diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetStats.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetStats.kt new file mode 100644 index 000000000..0ce8ab9b3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetStats.kt @@ -0,0 +1,42 @@ +package org.ooni.probe.domain + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.isoDayNumber +import kotlinx.datetime.minus +import org.ooni.probe.data.models.MeasurementStats +import org.ooni.probe.shared.toDateTime +import org.ooni.probe.shared.today + +class GetStats( + private val countMeasurementsFromStartTime: (LocalDateTime) -> Flow, + private val countNetworkAsns: () -> Flow, + private val countNetworkCountries: () -> Flow, +) { + operator fun invoke(): Flow { + val today = LocalDate.today() + val startOfWeek = today.minus(today.dayOfWeek.isoDayNumber - 1, DateTimeUnit.DAY) + val startOfMonth = today.minus(today.day - 1, DateTimeUnit.DAY) + val startOfTotal = LocalDate.fromEpochDays(0) + return combine( + countMeasurementsFromStartTime(today.toDateTime()), + countMeasurementsFromStartTime(startOfWeek.toDateTime()), + countMeasurementsFromStartTime(startOfMonth.toDateTime()), + countMeasurementsFromStartTime(startOfTotal.toDateTime()), + countNetworkAsns(), + countNetworkCountries(), + ) { values -> + MeasurementStats( + values[0], + values[1], + values[2], + values[3], + values[4], + values[5], + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/shared/DateTimeExt.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/shared/DateTimeExt.kt index db266f2f5..534448564 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/shared/DateTimeExt.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/shared/DateTimeExt.kt @@ -16,6 +16,8 @@ fun LocalDate.toEpoch() = atStartOfDayIn(TimeZone.currentSystemDefault()).toEpoc fun LocalDate.toEpochInUTC() = atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() +fun LocalDate.toDateTime() = atStartOfDayIn(TimeZone.currentSystemDefault()).toLocalDateTime() + fun Long.toLocalDateTime() = Instant.fromEpochMilliseconds(this).toLocalDateTime() fun Long.toLocalDateFromUtc() = Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.UTC).date diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/DataUsageFormats.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/shared/NumberExt.kt similarity index 62% rename from composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/DataUsageFormats.kt rename to composeApp/src/commonMain/kotlin/org/ooni/probe/shared/NumberExt.kt index f2a62fd14..aab9a162f 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/DataUsageFormats.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/shared/NumberExt.kt @@ -1,4 +1,4 @@ -package org.ooni.probe.ui.shared +package org.ooni.probe.shared import kotlin.math.abs import kotlin.math.log10 @@ -16,3 +16,12 @@ fun Double.format(decimalChars: Int = 2): String { val decimalValue = abs((this - absoluteValue) * 10.0.pow(decimalChars)).toInt() return if (decimalValue == 0) absoluteValue.toString() else "$absoluteValue.$decimalValue" } + +fun Long.largeNumberShort(): String { + if (this <= 0) return "0" + val units = arrayOf("", "K", "M") + val digitGroups = (log10(this.toDouble()) / log10(1000.0)).toInt() + return (this / 1000.0.pow(digitGroups.toDouble())).withFractionalDigits() + units[digitGroups] +} + +fun Double.withFractionalDigits(): String = if (this < 10) format(2) else format(1) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt index 398d047cd..aa48e808a 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt @@ -43,7 +43,10 @@ fun DashboardCard( icon, contentDescription = null, tint = LocalContentColor.current.copy(alpha = 0.075f), - modifier = Modifier.size(88.dp).align(Alignment.TopEnd).padding(top = 4.dp), + modifier = Modifier + .size(88.dp) + .align(Alignment.TopEnd) + .padding(top = 4.dp), ) } Column { 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 b6fe7dcc1..d1c1ea4b2 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 @@ -35,16 +35,23 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.LifecycleResumeEffect import ooniprobe.composeapp.generated.resources.Common_Dismiss +import ooniprobe.composeapp.generated.resources.Common_Month +import ooniprobe.composeapp.generated.resources.Common_Today +import ooniprobe.composeapp.generated.resources.Common_Total +import ooniprobe.composeapp.generated.resources.Common_Week import ooniprobe.composeapp.generated.resources.Dashboard_AutoRun_Disabled import ooniprobe.composeapp.generated.resources.Dashboard_AutoRun_Enabled import ooniprobe.composeapp.generated.resources.Dashboard_LastResults import ooniprobe.composeapp.generated.resources.Dashboard_LastResults_SeeResults +import ooniprobe.composeapp.generated.resources.Dashboard_Stats_Countries +import ooniprobe.composeapp.generated.resources.Dashboard_Stats_Empty +import ooniprobe.composeapp.generated.resources.Dashboard_Stats_Networks +import ooniprobe.composeapp.generated.resources.Dashboard_Stats_Title import ooniprobe.composeapp.generated.resources.Dashboard_TestsMoved_Action import ooniprobe.composeapp.generated.resources.Dashboard_TestsMoved_Description import ooniprobe.composeapp.generated.resources.Dashboard_TestsMoved_Title @@ -56,6 +63,7 @@ import ooniprobe.composeapp.generated.resources.TestResults_Overview_Websites_Te import ooniprobe.composeapp.generated.resources.app_name import ooniprobe.composeapp.generated.resources.dashboard_arc import ooniprobe.composeapp.generated.resources.ic_auto_run +import ooniprobe.composeapp.generated.resources.ic_heart import ooniprobe.composeapp.generated.resources.ic_history import ooniprobe.composeapp.generated.resources.ic_measurement_anomaly import ooniprobe.composeapp.generated.resources.ic_measurement_failed @@ -68,8 +76,10 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview +import org.ooni.probe.data.models.MeasurementStats import org.ooni.probe.data.models.Run import org.ooni.probe.data.models.RunBackgroundState +import org.ooni.probe.shared.largeNumberShort import org.ooni.probe.ui.shared.IgnoreBatteryOptimizationDialog import org.ooni.probe.ui.shared.TestRunErrorMessages import org.ooni.probe.ui.shared.VerticalScrollbar @@ -77,6 +87,8 @@ import org.ooni.probe.ui.shared.isHeightCompact import org.ooni.probe.ui.shared.relativeDateTime import org.ooni.probe.ui.theme.AppTheme import org.ooni.probe.ui.theme.LocalCustomColors +import org.ooni.probe.ui.theme.cardTitle +import org.ooni.probe.ui.theme.dashboardSectionTitle @Composable fun DashboardScreen( @@ -149,6 +161,8 @@ fun DashboardScreen( if (state.showTestsMovedNotice) { TestsMoved(onEvent) } + + StatsSection(state.stats) } VerticalScrollbar(state = scrollState, modifier = Modifier.align(Alignment.CenterEnd)) } @@ -252,11 +266,7 @@ private fun LastRun( title = { Text( stringResource(Res.string.Dashboard_LastResults), - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 18.sp, - lineHeight = 24.sp, - fontWeight = FontWeight.Bold, - ), + style = MaterialTheme.typography.cardTitle, ) Text( run.startTime.relativeDateTime(), @@ -351,11 +361,7 @@ private fun TestsMoved(onEvent: (DashboardViewModel.Event) -> Unit) { title = { Text( stringResource(Res.string.Dashboard_TestsMoved_Title), - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 18.sp, - lineHeight = 24.sp, - fontWeight = FontWeight.Bold, - ), + style = MaterialTheme.typography.cardTitle, ) }, content = { @@ -378,6 +384,75 @@ private fun TestsMoved(onEvent: (DashboardViewModel.Event) -> Unit) { ) } +@Composable +private fun StatsSection(stats: MeasurementStats?) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .padding(horizontal = 16.dp), + ) { + Text( + stringResource(Res.string.Dashboard_Stats_Title), + style = MaterialTheme.typography.dashboardSectionTitle, + ) + Icon( + painterResource(Res.drawable.ic_heart), + contentDescription = null, + modifier = Modifier.padding(start = 8.dp).size(16.dp), + ) + } + + @Composable + fun StatsEntry( + key: String, + value: Long?, + ) { + Row( + verticalAlignment = Alignment.Bottom, + modifier = Modifier.padding(horizontal = 8.dp).padding(top = 8.dp), + ) { + Text( + value?.largeNumberShort().orEmpty(), + style = MaterialTheme.typography.bodyLarge.copy(fontSize = 24.sp), + ) + Text( + key.uppercase(), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 4.dp, bottom = 2.dp), + ) + } + } + + FlowRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + if (stats?.measurementsTotal == 0L) { + Text(stringResource(Res.string.Dashboard_Stats_Empty)) + } else { + StatsEntry(stringResource(Res.string.Common_Today), stats?.measurementsToday) + StatsEntry(stringResource(Res.string.Common_Week), stats?.measurementsWeek) + StatsEntry(stringResource(Res.string.Common_Month), stats?.measurementsMonth) + StatsEntry(stringResource(Res.string.Common_Total), stats?.measurementsTotal) + StatsEntry( + pluralStringResource( + Res.plurals.Dashboard_Stats_Networks, + stats?.networks?.toInt() ?: 0, + ), + stats?.networks, + ) + StatsEntry( + pluralStringResource( + Res.plurals.Dashboard_Stats_Countries, + stats?.countries?.toInt() ?: 0, + ), + stats?.countries, + ) + } + } +} + @Preview @Composable fun DashboardScreenPreview() { 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 79a5bca0c..61c0cb16e 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 @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.update import org.ooni.probe.config.BatteryOptimization import org.ooni.probe.data.models.AutoRunParameters +import org.ooni.probe.data.models.MeasurementStats import org.ooni.probe.data.models.Run import org.ooni.probe.data.models.RunBackgroundState import org.ooni.probe.data.models.SettingsKey @@ -39,6 +40,7 @@ class DashboardViewModel( dismissLastRun: suspend () -> Unit, getPreference: (SettingsKey) -> Flow, setPreference: suspend (SettingsKey, Any) -> Unit, + getStats: () -> Flow, batteryOptimization: BatteryOptimization, ) : ViewModel() { private val events = MutableSharedFlow(extraBufferCapacity = 1) @@ -92,6 +94,11 @@ class DashboardViewModel( _state.update { it.copy(showTestsMovedNotice = preference != true) } }.launchIn(viewModelScope) + getStats() + .onEach { stats -> + _state.update { it.copy(stats = stats) } + }.launchIn(viewModelScope) + events .filterIsInstance() .onEach { goToRunTests() } @@ -168,6 +175,7 @@ class DashboardViewModel( data class State( val runBackgroundState: RunBackgroundState = RunBackgroundState.Idle, val isAutoRunEnabled: Boolean = false, + val stats: MeasurementStats? = null, val testRunErrors: List = emptyList(), val showVpnWarning: Boolean = false, val lastRun: Run? = null, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/result/ResultScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/result/ResultScreen.kt index 830c3dd10..5b7df28c6 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/result/ResultScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/result/ResultScreen.kt @@ -95,6 +95,7 @@ import org.ooni.probe.data.models.downloadSpeed import org.ooni.probe.data.models.ping import org.ooni.probe.data.models.uploadSpeed import org.ooni.probe.data.models.videoQuality +import org.ooni.probe.shared.formatDataUsage import org.ooni.probe.ui.result.ResultViewModel.MeasurementGroupItem.Group import org.ooni.probe.ui.result.ResultViewModel.MeasurementGroupItem.Single import org.ooni.probe.ui.results.UploadResults @@ -102,7 +103,6 @@ import org.ooni.probe.ui.shared.NavigationBackButton import org.ooni.probe.ui.shared.TopBar import org.ooni.probe.ui.shared.VerticalScrollbar import org.ooni.probe.ui.shared.format -import org.ooni.probe.ui.shared.formatDataUsage import org.ooni.probe.ui.shared.isHeightCompact import org.ooni.probe.ui.shared.longFormat import org.ooni.probe.ui.theme.LocalCustomColors diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt index f1390f416..3a6b99fc5 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt @@ -93,12 +93,12 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.ooni.probe.data.models.ResultFilter +import org.ooni.probe.shared.formatDataUsage import org.ooni.probe.shared.stringMonthArrayResource import org.ooni.probe.shared.today import org.ooni.probe.ui.shared.LightStatusBars import org.ooni.probe.ui.shared.TopBar import org.ooni.probe.ui.shared.VerticalScrollbar -import org.ooni.probe.ui.shared.formatDataUsage import org.ooni.probe.ui.shared.isHeightCompact @Composable diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/theme/CustomType.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/theme/CustomType.kt new file mode 100644 index 000000000..a2b7db460 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/theme/CustomType.kt @@ -0,0 +1,23 @@ +package org.ooni.probe.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography.cardTitle + @Composable + get() = MaterialTheme.typography.titleMedium.copy( + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Bold, + ) + +val Typography.dashboardSectionTitle + @Composable + get() = MaterialTheme.typography.titleMedium.copy( + fontSize = 20.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.Bold, + ) diff --git a/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Measurement.sq b/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Measurement.sq index 5744e02b7..62a37c957 100644 --- a/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Measurement.sq +++ b/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Measurement.sq @@ -123,3 +123,7 @@ SELECT * FROM Measurement LEFT JOIN Url ON Measurement.url_id = Url.id WHERE Measurement.id = :measurementId LIMIT 1; + +countFromStartTime: +SELECT COUNT(*) FROM Measurement +WHERE Measurement.start_time > :fromStartTime AND Measurement.is_done = 1; diff --git a/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Network.sq b/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Network.sq index 48ba2db38..7c5b3f3ac 100644 --- a/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Network.sq +++ b/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Network.sq @@ -35,3 +35,10 @@ selectByValues: SELECT * FROM Network WHERE network_name = ? AND asn = ? AND country_code = ? AND network_type = ? LIMIT 1; + +countAsns: +SELECT COUNT(DISTINCT Network.asn) FROM Network; + +countCountries: +SELECT COUNT(DISTINCT Network.country_code) FROM Network +WHERE Network.country_code IS NOT NULL OR Network.country_code <> ''; From 924459fbb0221e102e790533d7eb49d0faf97340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Tue, 21 Oct 2025 18:48:25 +0100 Subject: [PATCH 06/21] OONI articles --- .../ooni/probe/uitesting/RunningTestsTest.kt | 13 +- .../uitesting/helpers/ComposeTestHelpers.kt | 5 +- .../uitesting/helpers/StateTestHelpers.kt | 7 +- .../values/strings-common.xml | 13 +- .../commonMain/kotlin/org/ooni/probe/App.kt | 1 + .../ooni/probe/config/OrganizationConfig.kt | 1 + .../ooni/probe/data/models/ArticleModel.kt | 34 ++++ .../data/repositories/ArticleRepository.kt | 53 +++++++ .../kotlin/org/ooni/probe/di/Dependencies.kt | 44 ++++++ .../ooni/probe/domain/articles/GetArticles.kt | 10 ++ .../ooni/probe/domain/articles/GetFindings.kt | 72 +++++++++ .../ooni/probe/domain/articles/GetRSSFeed.kt | 120 ++++++++++++++ .../probe/domain/articles/RefreshArticles.kt | 48 ++++++ .../org/ooni/probe/ui/articles/ArticleCell.kt | 90 +++++++++++ .../ooni/probe/ui/articles/ArticleScreen.kt | 146 ++++++++++++++++++ .../probe/ui/articles/ArticleViewModel.kt | 64 ++++++++ .../ooni/probe/ui/articles/ArticlesScreen.kt | 99 ++++++++++++ .../probe/ui/articles/ArticlesViewModel.kt | 71 +++++++++ .../probe/ui/dashboard/DashboardScreen.kt | 53 +++++++ .../probe/ui/dashboard/DashboardViewModel.kt | 33 ++++ .../probe/ui/measurement/MeasurementScreen.kt | 40 +---- .../ui/navigation/BottomNavigationBar.kt | 4 +- .../ooni/probe/ui/navigation/Navigation.kt | 27 ++++ .../org/ooni/probe/ui/navigation/Screen.kt | 62 +++++--- .../ooni/probe/ui/results/ResultsScreen.kt | 7 +- .../org/ooni/probe/ui/shared/DateFormats.kt | 16 ++ .../ui/shared/WebViewProgressIndicator.kt | 40 +++++ .../commonMain/sqldelight/migrations/15.sqm | 7 + .../sqldelight/org/ooni/probe/data/Article.sq | 23 +++ .../ooni/probe/config/OrganizationConfig.kt | 1 + .../ooni/probe/config/OrganizationConfig.kt | 1 + gradle/libs.versions.toml | 6 +- 32 files changed, 1135 insertions(+), 76 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/ArticleModel.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/ArticleRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetArticles.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetRSSFeed.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleCell.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/WebViewProgressIndicator.kt create mode 100644 composeApp/src/commonMain/sqldelight/migrations/15.sqm create mode 100644 composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Article.sq diff --git a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/RunningTestsTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/RunningTestsTest.kt index 0c52e8663..dd8457d8a 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/RunningTestsTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/RunningTestsTest.kt @@ -8,9 +8,10 @@ import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.test.runTest import ooniprobe.composeapp.generated.resources.Common_Expand +import ooniprobe.composeapp.generated.resources.Dashboard_LastResults_SeeResults import ooniprobe.composeapp.generated.resources.Dashboard_RunTests_RunButton_Label import ooniprobe.composeapp.generated.resources.Dashboard_RunTests_SelectNone -import ooniprobe.composeapp.generated.resources.Dashboard_RunV2_RunFinished +import ooniprobe.composeapp.generated.resources.Dashboard_LastResults_SeeResults import ooniprobe.composeapp.generated.resources.Measurement_Title import ooniprobe.composeapp.generated.resources.OONIRun_Run import ooniprobe.composeapp.generated.resources.Res @@ -66,7 +67,7 @@ class RunningTestsTest { clickOnText(Res.string.Test_Signal_Fullname) clickOnRunButton(1) - clickOnText(Res.string.Dashboard_RunV2_RunFinished, timeout = TEST_WAIT_TIMEOUT) + clickOnText(Res.string.Dashboard_LastResults_SeeResults, timeout = TEST_WAIT_TIMEOUT) clickOnText(Res.string.Test_InstantMessaging_Fullname) clickOnText(Res.string.Test_Signal_Fullname) @@ -87,7 +88,7 @@ class RunningTestsTest { clickOnText(Res.string.Test_Psiphon_Fullname) clickOnRunButton(1) - clickOnText(Res.string.Dashboard_RunV2_RunFinished, timeout = TEST_WAIT_TIMEOUT) + clickOnText(Res.string.Dashboard_LastResults_SeeResults, timeout = TEST_WAIT_TIMEOUT) clickOnText(Res.string.Test_Circumvention_Fullname) clickOnText(Res.string.Test_Psiphon_Fullname) @@ -108,7 +109,7 @@ class RunningTestsTest { clickOnText("HTTP Header", substring = true) clickOnRunButton(1) - clickOnText(Res.string.Dashboard_RunV2_RunFinished, timeout = TEST_WAIT_TIMEOUT) + clickOnText(Res.string.Dashboard_LastResults_SeeResults, timeout = TEST_WAIT_TIMEOUT) clickOnText(Res.string.Test_Performance_Fullname) clickOnText("HTTP Header", substring = true) @@ -129,7 +130,7 @@ class RunningTestsTest { clickOnText("stunreachability", substring = true) clickOnRunButton(1) - clickOnText(Res.string.Dashboard_RunV2_RunFinished, timeout = TEST_WAIT_TIMEOUT) + clickOnText(Res.string.Dashboard_LastResults_SeeResults, timeout = TEST_WAIT_TIMEOUT) clickOnText(Res.string.Test_Experimental_Fullname) compose.onAllNodesWithText("stunreachability")[0].performClick() @@ -150,7 +151,7 @@ class RunningTestsTest { clickOnText("Trusted International Media") clickOnRunButton(1) - clickOnText(Res.string.Dashboard_RunV2_RunFinished, timeout = TEST_WAIT_TIMEOUT) + clickOnText(Res.string.Dashboard_LastResults_SeeResults, timeout = TEST_WAIT_TIMEOUT) clickOnText("Trusted International Media") clickOnText("https://www.dw.com") diff --git a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/ComposeTestHelpers.kt b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/ComposeTestHelpers.kt index 2255c263f..618f06417 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/ComposeTestHelpers.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/ComposeTestHelpers.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -31,8 +32,8 @@ fun ComposeTestRule.clickOnText( substring: Boolean = false, timeout: Duration = DEFAULT_WAIT_TIMEOUT, ): SemanticsNodeInteraction { - wait(timeout) { onNodeWithText(text, substring = substring).isDisplayed() } - return onNodeWithText(text, substring = substring).performClick() + wait(timeout) { onAllNodesWithText(text, substring = substring).onFirst().isDisplayed() } + return onAllNodesWithText(text, substring = substring).onFirst().performClick() } suspend fun ComposeTestRule.clickOnContentDescription(stringRes: StringResource) = clickOnContentDescription(getString(stringRes)) diff --git a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/StateTestHelpers.kt b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/StateTestHelpers.kt index 967435543..f4a584b54 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/StateTestHelpers.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/StateTestHelpers.kt @@ -4,7 +4,12 @@ import org.ooni.probe.data.models.SettingsKey import org.ooni.probe.domain.organizationPreferenceDefaults suspend fun skipOnboarding() { - preferences.setValueByKey(SettingsKey.FIRST_RUN, false) + preferences.setValuesByKey( + listOf( + SettingsKey.FIRST_RUN to false, + SettingsKey.TESTS_MOVED_NOTICE to true, + ) + ) } suspend fun defaultSettings() { diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index 5e68e96c8..0caceb1c4 100644 --- a/composeApp/src/commonMain/composeResources/values/strings-common.xml +++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml @@ -42,6 +42,13 @@ Start running tests to see your statistics here. + OONI News + Blog Post + Finding + Report + Read More + Recent + OONI Tests OONI Run Links Created by %1$s on %2$s @@ -92,11 +99,9 @@ Tor Test Signal Test - + - Test Results - Test Results - Results + Results Networks Data Usage diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt index 25fe2c859..6c4cf75fa 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt @@ -125,6 +125,7 @@ fun App( LaunchedEffect(Unit) { dependencies.finishInProgressData() dependencies.deleteOldResults() + dependencies.refreshArticles() } LaunchedEffect(Unit) { dependencies.observeAndConfigureAutoRun() diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt index 3a008cf5a..fb96850cb 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt @@ -10,6 +10,7 @@ interface OrganizationConfigInterface { val updateDescriptorTaskId: String val hasWebsitesDescriptor: Boolean val donateUrl: String? + val hasOoniNews: Boolean val ooniApiBaseUrl get() = BuildTypeDefaults.ooniApiBaseUrl val ooniRunDomain get() = BuildTypeDefaults.ooniRunDomain diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/ArticleModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/ArticleModel.kt new file mode 100644 index 000000000..318e5dbcf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/ArticleModel.kt @@ -0,0 +1,34 @@ +package org.ooni.probe.data.models + +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.minus +import org.ooni.probe.shared.today + +data class ArticleModel( + val url: Url, + val title: String, + val description: String?, + val source: Source, + val time: LocalDateTime, +) { + data class Url( + val value: String, + ) + + val isRecent get() = time.date >= LocalDate.today().minus(7, DateTimeUnit.DAY) + + enum class Source( + val value: String, + ) { + Blog("blog"), + Finding("finding"), + Report("report"), + ; + + companion object { + fun fromValue(value: String) = entries.firstOrNull { it.value == value } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/ArticleRepository.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/ArticleRepository.kt new file mode 100644 index 000000000..3e7043380 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/ArticleRepository.kt @@ -0,0 +1,53 @@ +package org.ooni.probe.data.repositories + +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import org.ooni.probe.Database +import org.ooni.probe.data.Article +import org.ooni.probe.data.models.ArticleModel +import org.ooni.probe.shared.toEpoch +import org.ooni.probe.shared.toLocalDateTime +import kotlin.coroutines.CoroutineContext + +class ArticleRepository( + private val database: Database, + private val backgroundContext: CoroutineContext, +) { + suspend fun refresh(models: List) { + withContext(backgroundContext) { + database.transaction { + models.forEach { model -> + database.articleQueries.insertOrReplace( + url = model.url.value, + title = model.title, + description = model.description, + source = model.source.value, + time = model.time.toEpoch(), + ) + } + database.articleQueries.deleteExceptUrls(models.map { it.url.value }) + } + } + } + + fun list(): Flow> = + database.articleQueries + .selectAll() + .asFlow() + .mapToList(backgroundContext) + .map { list -> list.mapNotNull { it.toModel() } } + + private fun Article.toModel() = + run { + ArticleModel( + url = ArticleModel.Url(url), + title = title, + description = description, + source = ArticleModel.Source.fromValue(source) ?: return@run null, + time = time.toLocalDateTime(), + ) + } +} 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 dfa173aac..91c585386 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -28,6 +28,7 @@ import org.ooni.probe.data.disk.ReadFile import org.ooni.probe.data.disk.ReadFileOkio import org.ooni.probe.data.disk.WriteFile import org.ooni.probe.data.disk.WriteFileOkio +import org.ooni.probe.data.models.ArticleModel import org.ooni.probe.data.models.AutoRunParameters import org.ooni.probe.data.models.BatteryState import org.ooni.probe.data.models.InstalledTestDescriptorModel @@ -38,6 +39,7 @@ import org.ooni.probe.data.models.PreferenceCategoryKey import org.ooni.probe.data.models.ResultModel import org.ooni.probe.data.models.RunSpecification import org.ooni.probe.data.repositories.AppReviewRepository +import org.ooni.probe.data.repositories.ArticleRepository import org.ooni.probe.data.repositories.MeasurementRepository import org.ooni.probe.data.repositories.NetworkRepository import org.ooni.probe.data.repositories.PreferenceRepository @@ -72,6 +74,8 @@ import org.ooni.probe.domain.ShouldShowVpnWarning import org.ooni.probe.domain.UploadMissingMeasurements import org.ooni.probe.domain.appreview.MarkAppReviewAsShown import org.ooni.probe.domain.appreview.ShouldShowAppReview +import org.ooni.probe.domain.articles.GetArticles +import org.ooni.probe.domain.articles.RefreshArticles import org.ooni.probe.domain.descriptors.AcceptDescriptorUpdate import org.ooni.probe.domain.descriptors.BootstrapTestDescriptors import org.ooni.probe.domain.descriptors.DeleteTestDescriptor @@ -95,6 +99,8 @@ import org.ooni.probe.domain.results.GetResults import org.ooni.probe.shared.PlatformInfo import org.ooni.probe.shared.monitoring.AppLogger import org.ooni.probe.shared.monitoring.CrashMonitoring +import org.ooni.probe.ui.articles.ArticleViewModel +import org.ooni.probe.ui.articles.ArticlesViewModel import org.ooni.probe.ui.choosewebsites.ChooseWebsitesViewModel import org.ooni.probe.ui.dashboard.DashboardViewModel import org.ooni.probe.ui.descriptor.DescriptorViewModel @@ -157,6 +163,9 @@ class Dependencies( private val appReviewRepository by lazy { AppReviewRepository(dataStore) } + @VisibleForTesting + val articleRepository by lazy { ArticleRepository(database, backgroundContext) } + @VisibleForTesting val measurementRepository by lazy { MeasurementRepository(database, json, backgroundContext) @@ -315,6 +324,9 @@ class Dependencies( updateState = descriptorUpdateStateManager::update, ) } + private val getArticles by lazy { + GetArticles(articleRepository::list) + } val getAutoRunSettings by lazy { GetAutoRunSettings(preferenceRepository::allSettings) } private val getAutoRunSpecification by lazy { GetAutoRunSpecification(getTestDescriptors::latest, preferenceRepository) @@ -486,6 +498,12 @@ class Dependencies( private val shouldShowVpnWarning by lazy { ShouldShowVpnWarning(preferenceRepository, networkTypeFinder::invoke) } + val refreshArticles by lazy { + RefreshArticles( + httpDo = engine::httpDo, + refreshArticlesInDatabase = articleRepository::refresh, + ) + } val runBackgroundStateManager by lazy { RunBackgroundStateManager() } private val undoRejectedDescriptorUpdate by lazy { UndoRejectedDescriptorUpdate( @@ -565,6 +583,27 @@ class Dependencies( startBackgroundRun = startSingleRunInner, ) + fun articleViewModel( + url: ArticleModel.Url, + onBack: () -> Unit, + ) = ArticleViewModel( + url = url, + onBack = onBack, + launchAction = launchAction::invoke, + isWebViewAvailable = isWebViewAvailable, + ) + + fun articlesViewModel( + onBack: () -> Unit, + goToArticle: (ArticleModel.Url) -> Unit, + ) = ArticlesViewModel( + onBack = onBack, + goToArticle = goToArticle, + getArticles = getArticles::invoke, + refreshArticles = refreshArticles::invoke, + canPullToRefresh = platformInfo.canPullToRefresh, + ) + fun chooseWebsitesViewModel( initialUrl: String?, onBack: () -> Unit, @@ -585,6 +624,8 @@ class Dependencies( goToRunTests: () -> Unit, goToTests: () -> Unit, goToTestSettings: () -> Unit, + goToArticles: () -> Unit, + goToArticle: (ArticleModel.Url) -> Unit, ) = DashboardViewModel( goToOnboarding = goToOnboarding, goToResults = goToResults, @@ -592,6 +633,8 @@ class Dependencies( goToRunTests = goToRunTests, goToTests = goToTests, goToTestSettings = goToTestSettings, + goToArticles = goToArticles, + goToArticle = goToArticle, getFirstRun = getFirstRun::invoke, observeRunBackgroundState = runBackgroundStateManager::observeState, observeTestRunErrors = runBackgroundStateManager::observeErrors, @@ -602,6 +645,7 @@ class Dependencies( getPreference = preferenceRepository::getValueByKey, setPreference = preferenceRepository::setValueByKey, getStats = getStats::invoke, + getArticles = getArticles::invoke, batteryOptimization = batteryOptimization, ) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetArticles.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetArticles.kt new file mode 100644 index 000000000..6ca7a86aa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetArticles.kt @@ -0,0 +1,10 @@ +package org.ooni.probe.domain.articles + +import kotlinx.coroutines.flow.Flow +import org.ooni.probe.data.models.ArticleModel + +class GetArticles( + val getArticles: () -> Flow>, +) { + operator fun invoke() = getArticles() +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt new file mode 100644 index 000000000..6e8b4ba51 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt @@ -0,0 +1,72 @@ +package org.ooni.probe.domain.articles + +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.format.FormatStringsInDatetimeFormats +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.ooni.engine.Engine.MkException +import org.ooni.engine.models.Failure +import org.ooni.engine.models.Result +import org.ooni.engine.models.Success +import org.ooni.engine.models.TaskOrigin +import org.ooni.probe.data.models.ArticleModel +import org.ooni.probe.shared.toLocalDateTime +import kotlin.time.Instant + +class GetFindings( + val httpDo: suspend (String, String, TaskOrigin) -> Result, +) : RefreshArticles.Source { + override suspend operator fun invoke(): Result, Exception> { + return httpDo("GET", "https://api.ooni.org/api/v1/incidents/search", TaskOrigin.OoniRun) + .mapError { Exception("Failed to get findings", it) } + .flatMap { response -> + if (response.isNullOrBlank()) return@flatMap Failure(Exception("Empty response")) + + val wrapper = try { + Json.decodeFromString(response) + } catch (e: Exception) { + return@flatMap Failure(Exception("Could not parse indidents API response", e)) + } + + Success(wrapper.incidents?.mapNotNull { it.toArticle() }.orEmpty()) + } + } + + private fun Wrapper.Incident.toArticle() = + run { + ArticleModel( + url = id?.let { ArticleModel.Url("https://explorer.ooni.org/findings/$it") } + ?: return@run null, + title = title ?: return@run null, + source = ArticleModel.Source.Finding, + description = shortDescription, + time = createTime?.toLocalDateTime() ?: return@run null, + ) + } + + @OptIn(FormatStringsInDatetimeFormats::class) + private fun String.toLocalDateTime(): LocalDateTime? = Instant.parse(this).toLocalDateTime() + + companion object { + private val Json by lazy { + Json { + ignoreUnknownKeys = true + } + } + } + + @Serializable + data class Wrapper( + @SerialName("incidents") + val incidents: List?, + ) { + @Serializable + data class Incident( + @SerialName("id") val id: String?, + @SerialName("title") val title: String?, + @SerialName("short_description") val shortDescription: String?, + @SerialName("create_time") val createTime: String?, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetRSSFeed.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetRSSFeed.kt new file mode 100644 index 000000000..e68857b87 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetRSSFeed.kt @@ -0,0 +1,120 @@ +package org.ooni.probe.domain.articles + +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.format.DateTimeComponents +import kotlinx.datetime.format.DayOfWeekNames +import kotlinx.datetime.format.FormatStringsInDatetimeFormats +import kotlinx.datetime.format.MonthNames +import kotlinx.datetime.format.byUnicodePattern +import kotlinx.datetime.parse +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import nl.adaptivity.xmlutil.ExperimentalXmlUtilApi +import nl.adaptivity.xmlutil.serialization.UnknownChildHandler +import nl.adaptivity.xmlutil.serialization.XML +import nl.adaptivity.xmlutil.serialization.XmlElement +import nl.adaptivity.xmlutil.serialization.XmlSerialName +import org.ooni.engine.Engine.MkException +import org.ooni.engine.models.Failure +import org.ooni.engine.models.Result +import org.ooni.engine.models.Success +import org.ooni.engine.models.TaskOrigin +import org.ooni.probe.data.models.ArticleModel +import org.ooni.probe.shared.toLocalDateTime +import kotlin.time.Instant + +class GetRSSFeed( + val httpDo: suspend (String, String, TaskOrigin) -> Result, + val url: String, + val source: ArticleModel.Source, +) : RefreshArticles.Source { + override suspend operator fun invoke(): Result, Exception> { + return httpDo("GET", url, TaskOrigin.OoniRun) + .mapError { Exception("Failed to get blog posts", it) } + .flatMap { response -> + if (response.isNullOrBlank()) return@flatMap Failure(Exception("Empty response")) + + val rss = try { + Xml.decodeFromString(response) + } catch (e: Exception) { + return@flatMap Failure(Exception("Could not parse RSS feed", e)) + } + + Success( + rss.channel + ?.items + ?.mapNotNull { it.toArticle() } + .orEmpty(), + ) + } + } + + private fun Rss.Item.toArticle() = + run { + ArticleModel( + url = ArticleModel.Url(link ?: return@run null), + title = title ?: return@run null, + source = source, + description = description, + time = pubDate?.toLocalDateTime() ?: return@run null, + ) + } + + @OptIn(FormatStringsInDatetimeFormats::class) + private fun String.toLocalDateTime(): LocalDateTime? = + Instant + .parse( + this, + DateTimeComponents.Format { + dayOfWeek(DayOfWeekNames.ENGLISH_ABBREVIATED) + chars(", ") + day() + chars(" ") + monthName(MonthNames.ENGLISH_ABBREVIATED) + chars(" ") + byUnicodePattern("yyyy HH:mm:ss Z") + }, + ).toLocalDateTime() + + companion object Companion { + private val Xml by lazy { + XML { + defaultPolicy { + @OptIn(ExperimentalXmlUtilApi::class) + unknownChildHandler = UnknownChildHandler { _, _, _, _, _ -> emptyList() } + } + } + } + } + + @Serializable + @XmlSerialName("rss") + data class Rss( + @XmlSerialName("channel") + @XmlElement + val channel: Channel?, + ) { + @Serializable + data class Channel( + @XmlSerialName("item") + @XmlElement + val items: List?, + ) + + @Serializable + data class Item( + @XmlSerialName("title") + @XmlElement + val title: String?, + @XmlSerialName("link") + @XmlElement + val link: String?, + @XmlSerialName("description") + @XmlElement + val description: String?, + @XmlSerialName("pubDate") + @XmlElement + val pubDate: String?, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt new file mode 100644 index 000000000..297dbdb0e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt @@ -0,0 +1,48 @@ +package org.ooni.probe.domain.articles + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import org.ooni.engine.Engine.MkException +import org.ooni.engine.models.Failure +import org.ooni.engine.models.Result +import org.ooni.engine.models.TaskOrigin +import org.ooni.probe.config.OrganizationConfig +import org.ooni.probe.data.models.ArticleModel + +class RefreshArticles( + val httpDo: suspend (String, String, TaskOrigin) -> Result, + val refreshArticlesInDatabase: suspend (List) -> Unit, +) { + fun interface Source { + suspend operator fun invoke(): Result, Exception> + } + + suspend operator fun invoke() { + if (!OrganizationConfig.hasOoniNews) return + + val sources = listOf( + GetRSSFeed(httpDo, "https://ooni.org/blog/index.xml", ArticleModel.Source.Blog), + GetRSSFeed(httpDo, "https://ooni.org/reports/index.xml", ArticleModel.Source.Report), + GetFindings(httpDo), + ) + + val responses = sources + .map { + coroutineScope { async { it() } } + }.awaitAll() + + responses.forEach { response -> + response.onFailure { + Logger.w("Failed to get article source", it) + } + } + + if (responses.any { it is Failure }) return + + refreshArticlesInDatabase( + responses.mapNotNull { it.get() }.flatten(), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleCell.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleCell.kt new file mode 100644 index 000000000..26786b373 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleCell.kt @@ -0,0 +1,90 @@ +package org.ooni.probe.ui.articles + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Blog +import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Finding +import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Recent +import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Report +import ooniprobe.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import org.ooni.probe.data.models.ArticleModel +import org.ooni.probe.data.models.ArticleModel.Source.Blog +import org.ooni.probe.data.models.ArticleModel.Source.Finding +import org.ooni.probe.data.models.ArticleModel.Source.Report +import org.ooni.probe.ui.shared.articleFormat +import org.ooni.probe.ui.theme.LocalCustomColors + +@Composable +fun ArticleCell( + article: ArticleModel, + onClick: () -> Unit, +) { + OutlinedCard( + onClick = onClick, + modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 8.dp), + ) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text( + article.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Row(verticalAlignment = Alignment.Bottom) { + Text( + stringResource( + when (article.source) { + Blog -> Res.string.Dashboard_Articles_Blog + Finding -> Res.string.Dashboard_Articles_Finding + Report -> Res.string.Dashboard_Articles_Report + }, + ), + style = MaterialTheme.typography.labelLarge + .copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp), + ) + + Text( + "•", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 4.dp), + ) + Text( + article.time.articleFormat(), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(top = 4.dp), + ) + if (article.isRecent) { + Text( + "•", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 4.dp), + ) + Text( + stringResource(Res.string.Dashboard_Articles_Recent), + style = MaterialTheme.typography.labelLarge, + color = LocalCustomColors.current.success, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleScreen.kt new file mode 100644 index 000000000..e1c013664 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleScreen.kt @@ -0,0 +1,146 @@ +package org.ooni.probe.ui.articles + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import ooniprobe.composeapp.generated.resources.Common_Refresh +import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Title +import ooniprobe.composeapp.generated.resources.Measurement_LoadingFailed +import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.ic_cloud_off +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.ooni.probe.ui.shared.NavigationBackButton +import org.ooni.probe.ui.shared.OoniWebView +import org.ooni.probe.ui.shared.OoniWebViewController +import org.ooni.probe.ui.shared.TopBar +import org.ooni.probe.ui.shared.WebViewProgressIndicator + +@Composable +fun ArticleScreen( + state: ArticleViewModel.State, + onEvent: (ArticleViewModel.Event) -> Unit, +) { + val controller = remember { OoniWebViewController() } + + Column(Modifier.background(MaterialTheme.colorScheme.background)) { + Box { + TopBar( + title = { + Text(stringResource(Res.string.Dashboard_Articles_Title)) + }, + navigationIcon = { + NavigationBackButton({ onEvent(ArticleViewModel.Event.BackClicked) }) + }, + actions = { + IconButton(onClick = { onEvent(ArticleViewModel.Event.ShareUrl) }) { + Icon( + Icons.Default.Share, + contentDescription = null, + ) + } + if (controller.state is OoniWebViewController.State.Loading) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onPrimary, + trackColor = Color.Transparent, + strokeWidth = 2.dp, + modifier = Modifier + .padding(12.dp) + .size(24.dp), + ) + } else { + IconButton( + onClick = { controller.reload() }, + enabled = controller.state.isFinished, + ) { + Icon( + Icons.Default.Refresh, + contentDescription = stringResource(Res.string.Common_Refresh), + ) + } + } + }, + ) + + if (controller.state is OoniWebViewController.State.Initializing || + controller.state is OoniWebViewController.State.Loading + ) { + WebViewProgressIndicator( + (controller.state as? OoniWebViewController.State.Loading)?.progress ?: 0f, + ) + } + } + + if (state !is ArticleViewModel.State.Show) return@Column + + Box(modifier = Modifier.fillMaxSize()) { + val isFailure = controller.state is OoniWebViewController.State.Failure + + OoniWebView( + controller = controller, + modifier = Modifier + .fillMaxSize() + .alpha(if (isFailure) 0f else 1f) + .padding(WindowInsets.navigationBars.asPaddingValues()), + ) + + if (isFailure) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(Res.drawable.ic_cloud_off), + contentDescription = null, + modifier = Modifier.padding(bottom = 32.dp).size(48.dp), + ) + Text( + text = stringResource(Res.string.Measurement_LoadingFailed), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + OutlinedButton( + onClick = { controller.reload() }, + modifier = Modifier.padding(top = 32.dp), + ) { + Text( + stringResource(Res.string.Common_Refresh), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + } + } + + val url = (state as? ArticleViewModel.State.Show)?.url + LaunchedEffect(url) { + url?.let(controller::load) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleViewModel.kt new file mode 100644 index 000000000..5e688f7dc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleViewModel.kt @@ -0,0 +1,64 @@ +package org.ooni.probe.ui.articles + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.launch +import org.ooni.probe.data.models.ArticleModel +import org.ooni.probe.data.models.PlatformAction + +class ArticleViewModel( + url: ArticleModel.Url, + onBack: () -> Unit, + launchAction: (PlatformAction) -> Unit, + isWebViewAvailable: () -> Boolean, +) : ViewModel() { + private val events = MutableSharedFlow(extraBufferCapacity = 1) + + private val _state = MutableStateFlow(State.CheckingWebViewAvailability) + val state = _state.asStateFlow() + + init { + viewModelScope.launch { + if (isWebViewAvailable()) { + _state.value = State.Show(url.value) + } else { + launchAction(PlatformAction.OpenUrl(url.value)) + onBack() + } + } + + events + .filterIsInstance() + .onEach { onBack() } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { launchAction(PlatformAction.Share(url.value)) } + .launchIn(viewModelScope) + } + + fun onEvent(event: Event) { + events.tryEmit(event) + } + + sealed interface State { + data object CheckingWebViewAvailability : State + + data class Show( + val url: String, + ) : State + } + + sealed interface Event { + data object BackClicked : Event + + data object ShareUrl : Event + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesScreen.kt new file mode 100644 index 000000000..6bebe3f0e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesScreen.kt @@ -0,0 +1,99 @@ +package org.ooni.probe.ui.articles + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import ooniprobe.composeapp.generated.resources.Common_Refresh +import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Title +import ooniprobe.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import org.ooni.probe.ui.shared.NavigationBackButton +import org.ooni.probe.ui.shared.TopBar +import org.ooni.probe.ui.shared.VerticalScrollbar + +@Composable +fun ArticlesScreen( + state: ArticlesViewModel.State, + onEvent: (ArticlesViewModel.Event) -> Unit, +) { + val pullRefreshState = rememberPullToRefreshState() + Box( + Modifier + .pullToRefresh( + isRefreshing = state.isRefreshing, + onRefresh = { onEvent(ArticlesViewModel.Event.Refresh) }, + state = pullRefreshState, + enabled = state.canPullToRefresh, + ).background(MaterialTheme.colorScheme.background), + ) { + Column(Modifier.background(MaterialTheme.colorScheme.background)) { + TopBar( + title = { Text(stringResource(Res.string.Dashboard_Articles_Title)) }, + navigationIcon = { + NavigationBackButton({ onEvent(ArticlesViewModel.Event.BackClicked) }) + }, + actions = { + if (!state.canPullToRefresh) { + IconButton( + onClick = { onEvent(ArticlesViewModel.Event.Refresh) }, + ) { + Icon( + Icons.Default.Refresh, + contentDescription = stringResource(Res.string.Common_Refresh), + ) + } + } + }, + ) + + Box(Modifier.fillMaxSize()) { + val lazyListState = rememberLazyListState() + LazyColumn( + contentPadding = PaddingValues( + top = 16.dp, + bottom = 16.dp + + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding(), + ), + state = lazyListState, + ) { + items(state.articles, key = { it.url }) { article -> + ArticleCell( + article = article, + onClick = { onEvent(ArticlesViewModel.Event.ArticleClicked(article)) }, + ) + } + } + VerticalScrollbar( + state = lazyListState, + modifier = Modifier.align(Alignment.CenterEnd), + ) + } + } + PullToRefreshDefaults.Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = state.isRefreshing, + state = pullRefreshState, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesViewModel.kt new file mode 100644 index 000000000..9d707e6ac --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesViewModel.kt @@ -0,0 +1,71 @@ +package org.ooni.probe.ui.articles + +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.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import org.ooni.probe.data.models.ArticleModel + +class ArticlesViewModel( + onBack: () -> Unit, + goToArticle: (ArticleModel.Url) -> Unit, + getArticles: () -> Flow>, + refreshArticles: suspend () -> Unit, + canPullToRefresh: Boolean, +) : ViewModel() { + private val events = MutableSharedFlow(extraBufferCapacity = 1) + + private val _state = MutableStateFlow(State(canPullToRefresh = canPullToRefresh)) + val state = _state.asStateFlow() + + init { + getArticles() + .onEach { articles -> _state.update { it.copy(articles = articles) } } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { onBack() } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { goToArticle(it.article.url) } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { + if (state.value.isRefreshing) return@onEach + _state.update { it.copy(isRefreshing = true) } + refreshArticles() + _state.update { it.copy(isRefreshing = false) } + }.launchIn(viewModelScope) + } + + fun onEvent(event: Event) { + events.tryEmit(event) + } + + data class State( + val articles: List = emptyList(), + val isRefreshing: Boolean = false, + val canPullToRefresh: Boolean = false, + ) + + sealed interface Event { + data object BackClicked : Event + + data class ArticleClicked( + val article: ArticleModel, + ) : Event + + data object Refresh : Event + } +} 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 d1c1ea4b2..201dcef18 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 @@ -20,6 +20,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme @@ -35,7 +36,9 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.LifecycleResumeEffect @@ -44,6 +47,8 @@ import ooniprobe.composeapp.generated.resources.Common_Month import ooniprobe.composeapp.generated.resources.Common_Today import ooniprobe.composeapp.generated.resources.Common_Total import ooniprobe.composeapp.generated.resources.Common_Week +import ooniprobe.composeapp.generated.resources.Dashboard_Articles_ReadMore +import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Title import ooniprobe.composeapp.generated.resources.Dashboard_AutoRun_Disabled import ooniprobe.composeapp.generated.resources.Dashboard_AutoRun_Enabled import ooniprobe.composeapp.generated.resources.Dashboard_LastResults @@ -76,10 +81,13 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview +import org.ooni.probe.config.OrganizationConfig +import org.ooni.probe.data.models.ArticleModel import org.ooni.probe.data.models.MeasurementStats import org.ooni.probe.data.models.Run import org.ooni.probe.data.models.RunBackgroundState import org.ooni.probe.shared.largeNumberShort +import org.ooni.probe.ui.articles.ArticleCell import org.ooni.probe.ui.shared.IgnoreBatteryOptimizationDialog import org.ooni.probe.ui.shared.TestRunErrorMessages import org.ooni.probe.ui.shared.VerticalScrollbar @@ -148,6 +156,7 @@ fun DashboardScreen( modifier = Modifier .background(MaterialTheme.colorScheme.background) .verticalScroll(scrollState) + .padding(bottom = 16.dp) .fillMaxSize(), ) { if (state.showVpnWarning) { @@ -163,6 +172,7 @@ fun DashboardScreen( } StatsSection(state.stats) + ArticlesSection(state.articles, state.showReadMoreArticles, onEvent) } VerticalScrollbar(state = scrollState, modifier = Modifier.align(Alignment.CenterEnd)) } @@ -453,6 +463,49 @@ private fun StatsSection(stats: MeasurementStats?) { } } +@Composable +private fun ArticlesSection( + articles: List, + showReadMore: Boolean, + onEvent: (DashboardViewModel.Event) -> Unit, +) { + if (!OrganizationConfig.hasOoniNews) return + + HorizontalDivider( + thickness = Dp.Hairline, + modifier = Modifier.padding(vertical = 16.dp), + ) + + Text( + stringResource(Res.string.Dashboard_Articles_Title), + style = MaterialTheme.typography.dashboardSectionTitle, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + ) + + articles.forEach { article -> + ArticleCell( + article = article, + onClick = { onEvent(DashboardViewModel.Event.ArticleClicked(article)) }, + ) + } + + if (showReadMore) { + TextButton( + onClick = { onEvent(DashboardViewModel.Event.ReadMoreArticlesClicked) }, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text( + text = stringResource(Res.string.Dashboard_Articles_ReadMore), + textAlign = TextAlign.End, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + @Preview @Composable fun DashboardScreenPreview() { 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 61c0cb16e..63ae987f5 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 @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.update import org.ooni.probe.config.BatteryOptimization +import org.ooni.probe.data.models.ArticleModel import org.ooni.probe.data.models.AutoRunParameters import org.ooni.probe.data.models.MeasurementStats import org.ooni.probe.data.models.Run @@ -31,6 +32,8 @@ class DashboardViewModel( goToRunTests: () -> Unit, goToTests: () -> Unit, goToTestSettings: () -> Unit, + goToArticles: () -> Unit, + goToArticle: (ArticleModel.Url) -> Unit, getFirstRun: () -> Flow, observeRunBackgroundState: () -> Flow, observeTestRunErrors: () -> Flow, @@ -41,6 +44,7 @@ class DashboardViewModel( getPreference: (SettingsKey) -> Flow, setPreference: suspend (SettingsKey, Any) -> Unit, getStats: () -> Flow, + getArticles: () -> Flow>, batteryOptimization: BatteryOptimization, ) : ViewModel() { private val events = MutableSharedFlow(extraBufferCapacity = 1) @@ -99,6 +103,16 @@ class DashboardViewModel( _state.update { it.copy(stats = stats) } }.launchIn(viewModelScope) + getArticles() + .onEach { articles -> + _state.update { + it.copy( + articles = articles.take(ARTICLES_TO_SHOW), + showReadMoreArticles = articles.size > ARTICLES_TO_SHOW, + ) + } + }.launchIn(viewModelScope) + events .filterIsInstance() .onEach { goToRunTests() } @@ -134,6 +148,16 @@ class DashboardViewModel( .onEach { setPreference(SettingsKey.TESTS_MOVED_NOTICE, true) } .launchIn(viewModelScope) + events + .filterIsInstance() + .onEach { goToArticle(it.article.url) } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { goToArticles() } + .launchIn(viewModelScope) + events .filterIsInstance() .onEach { event -> @@ -176,6 +200,8 @@ class DashboardViewModel( val runBackgroundState: RunBackgroundState = RunBackgroundState.Idle, val isAutoRunEnabled: Boolean = false, val stats: MeasurementStats? = null, + val articles: List = emptyList(), + val showReadMoreArticles: Boolean = false, val testRunErrors: List = emptyList(), val showVpnWarning: Boolean = false, val lastRun: Run? = null, @@ -202,6 +228,12 @@ class DashboardViewModel( data object DismissTestsMovedClicked : Event + data class ArticleClicked( + val article: ArticleModel, + ) : Event + + data object ReadMoreArticlesClicked : Event + data class ErrorDisplayed( val error: TestRunError, ) : Event @@ -213,5 +245,6 @@ class DashboardViewModel( companion object { private val CHECK_VPN_WARNING_INTERVAL = 5.seconds + private const val ARTICLES_TO_SHOW = 3 } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/measurement/MeasurementScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/measurement/MeasurementScreen.kt index fdfb0aa46..fc30b8924 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/measurement/MeasurementScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/measurement/MeasurementScreen.kt @@ -3,13 +3,10 @@ package org.ooni.probe.ui.measurement import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column 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.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -19,7 +16,6 @@ import androidx.compose.material.icons.filled.Share import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -43,6 +39,7 @@ import org.ooni.probe.ui.shared.NavigationBackButton import org.ooni.probe.ui.shared.OoniWebView import org.ooni.probe.ui.shared.OoniWebViewController import org.ooni.probe.ui.shared.TopBar +import org.ooni.probe.ui.shared.WebViewProgressIndicator @Composable fun MeasurementScreen( @@ -61,11 +58,7 @@ fun MeasurementScreen( NavigationBackButton({ onEvent(MeasurementViewModel.Event.BackClicked) }) }, actions = { - IconButton( - onClick = { - onEvent(MeasurementViewModel.Event.ShareUrl) - }, - ) { + IconButton(onClick = { onEvent(MeasurementViewModel.Event.ShareUrl) }) { Icon( Icons.Default.Share, contentDescription = null, @@ -97,7 +90,7 @@ fun MeasurementScreen( if (controller.state is OoniWebViewController.State.Initializing || controller.state is OoniWebViewController.State.Loading ) { - ProgressIndicator( + WebViewProgressIndicator( (controller.state as? OoniWebViewController.State.Loading)?.progress ?: 0f, ) } @@ -151,30 +144,3 @@ fun MeasurementScreen( url?.let(controller::load) } } - -@Composable -private fun BoxScope.ProgressIndicator(progress: Float) { - val progressColor = MaterialTheme.colorScheme.onPrimary - val progressTrackColor = Color.Transparent - val progressModifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(bottom = 2.dp) - .height(2.dp) - - if (progress == 0f) { - LinearProgressIndicator( - color = progressColor, - trackColor = progressTrackColor, - modifier = progressModifier, - ) - } else { - LinearProgressIndicator( - progress = { progress }, - color = progressColor, - trackColor = progressTrackColor, - drawStopIndicator = {}, - modifier = progressModifier, - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt index 7ce7df4a8..6950f33ae 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt @@ -25,7 +25,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import ooniprobe.composeapp.generated.resources.Dashboard_Tab_Label import ooniprobe.composeapp.generated.resources.Res import ooniprobe.composeapp.generated.resources.Settings_Title -import ooniprobe.composeapp.generated.resources.TestResults_Overview_Tab_Label +import ooniprobe.composeapp.generated.resources.TestResults import ooniprobe.composeapp.generated.resources.Tests_Title import ooniprobe.composeapp.generated.resources.ic_dashboard import ooniprobe.composeapp.generated.resources.ic_history @@ -115,7 +115,7 @@ private val Screen.titleRes when (this) { Screen.Dashboard -> Res.string.Dashboard_Tab_Label Screen.Descriptors -> Res.string.Tests_Title - Screen.Results -> Res.string.TestResults_Overview_Tab_Label + Screen.Results -> Res.string.TestResults Screen.Settings -> Res.string.Settings_Title else -> throw IllegalArgumentException("Only main screens allowed in bottom navigation") } 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 b0108f1eb..4b06762a2 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 @@ -15,6 +15,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.dialog import androidx.navigation.toRoute +import org.ooni.probe.data.models.ArticleModel import org.ooni.probe.data.models.InstalledTestDescriptorModel import org.ooni.probe.data.models.MeasurementModel import org.ooni.probe.data.models.MeasurementsFilter @@ -22,6 +23,8 @@ import org.ooni.probe.data.models.PlatformAction import org.ooni.probe.data.models.PreferenceCategoryKey import org.ooni.probe.data.models.ResultModel import org.ooni.probe.di.Dependencies +import org.ooni.probe.ui.articles.ArticleScreen +import org.ooni.probe.ui.articles.ArticlesScreen import org.ooni.probe.ui.choosewebsites.ChooseWebsitesScreen import org.ooni.probe.ui.dashboard.DashboardScreen import org.ooni.probe.ui.descriptor.DescriptorScreen @@ -87,6 +90,8 @@ fun Navigation( Screen.SettingsCategory(PreferenceCategoryKey.TEST_OPTIONS.value), ) }, + goToArticles = { navController.safeNavigate(Screen.Articles) }, + goToArticle = { navController.safeNavigate(Screen.Article(it.value)) }, ) } val state by viewModel.state.collectAsState() @@ -379,6 +384,28 @@ fun Navigation( val state by viewModel.state.collectAsState() ChooseWebsitesScreen(state, viewModel::onEvent) } + + composable { entry -> + val viewModel = viewModel { + dependencies.articlesViewModel( + onBack = { navController.goBack() }, + goToArticle = { navController.safeNavigate(Screen.Article(it.value)) }, + ) + } + val state by viewModel.state.collectAsState() + ArticlesScreen(state, viewModel::onEvent) + } + + composable { entry -> + val viewModel = viewModel { + dependencies.articleViewModel( + url = ArticleModel.Url(entry.toRoute().url), + onBack = { navController.goBack() }, + ) + } + val state by viewModel.state.collectAsState() + ArticleScreen(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 3edbcef40..2f343d132 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 @@ -4,60 +4,86 @@ import kotlinx.serialization.Serializable @Serializable sealed interface Screen { - @Serializable data object Onboarding : Screen + @Serializable + data object Onboarding : Screen - @Serializable data object Dashboard : Screen + @Serializable + data object Dashboard : Screen - @Serializable data object Descriptors : Screen + @Serializable + data object Descriptors : Screen - @Serializable data object Results : Screen + @Serializable + data object Results : Screen - @Serializable data object Settings : Screen + @Serializable + data object Settings : Screen - @Serializable data class Result( + @Serializable + data class Result( val resultId: Long, ) : Screen - @Serializable data class AddDescriptor( + @Serializable + data class AddDescriptor( val runId: Long, ) : Screen - @Serializable data class Measurement( + @Serializable + data class Measurement( val measurementId: Long, ) : Screen - @Serializable data class MeasurementRaw( + @Serializable + data class MeasurementRaw( val measurementId: Long, ) : Screen - @Serializable data class SettingsCategory( + @Serializable + data class SettingsCategory( val category: String, ) : Screen - @Serializable data object AddProxy : Screen + @Serializable + data object AddProxy : Screen - @Serializable data object RunTests : Screen + @Serializable + data object RunTests : Screen - @Serializable data object RunningTest : Screen + @Serializable + data object RunningTest : Screen - @Serializable data class UploadMeasurements( + @Serializable + data class UploadMeasurements( val resultId: Long? = null, val measurementId: Long? = null, ) : Screen - @Serializable data class ChooseWebsites( + @Serializable + data class ChooseWebsites( val url: String? = null, ) : Screen - @Serializable data class Descriptor( + @Serializable + data class Descriptor( val descriptorKey: String, ) : Screen - @Serializable data class DescriptorWebsites( + @Serializable + data class DescriptorWebsites( val descriptorId: String, ) : Screen - @Serializable data class ReviewUpdates( + @Serializable + data class ReviewUpdates( val descriptorIds: List? = null, ) : Screen + + @Serializable + data object Articles : Screen + + @Serializable + data class Article( + val url: String, + ) : Screen } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt index 3a6b99fc5..a5b12914d 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt @@ -73,14 +73,13 @@ import ooniprobe.composeapp.generated.resources.Settings_Websites_Categories_Sel import ooniprobe.composeapp.generated.resources.Settings_Websites_Categories_Selection_None import ooniprobe.composeapp.generated.resources.Snackbar_ResultsSomeNotUploaded_Text import ooniprobe.composeapp.generated.resources.Snackbar_ResultsSomeNotUploaded_UploadAll +import ooniprobe.composeapp.generated.resources.TestResults import ooniprobe.composeapp.generated.resources.TestResults_Filter_DeleteConfirmation import ooniprobe.composeapp.generated.resources.TestResults_Filter_NoTestsFound import ooniprobe.composeapp.generated.resources.TestResults_Filters_Title import ooniprobe.composeapp.generated.resources.TestResults_Overview_Hero_DataUsage import ooniprobe.composeapp.generated.resources.TestResults_Overview_Hero_Networks -import ooniprobe.composeapp.generated.resources.TestResults_Overview_Hero_Results import ooniprobe.composeapp.generated.resources.TestResults_Overview_NoTestsHaveBeenRun -import ooniprobe.composeapp.generated.resources.TestResults_Overview_Title import ooniprobe.composeapp.generated.resources.TestResults_Summary_Performance_Hero_Download import ooniprobe.composeapp.generated.resources.TestResults_Summary_Performance_Hero_Upload import ooniprobe.composeapp.generated.resources.ic_delete_all @@ -120,7 +119,7 @@ fun ResultsScreen( if (!state.selectionEnabled) { TopBar( title = { - Text(stringResource(Res.string.TestResults_Overview_Title)) + Text(stringResource(Res.string.TestResults)) }, actions = { IconButton( @@ -441,7 +440,7 @@ private fun Summary(summary: ResultsViewModel.Summary?) { horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - stringResource(Res.string.TestResults_Overview_Hero_Results), + stringResource(Res.string.TestResults), style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(bottom = 8.dp), ) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/DateFormats.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/DateFormats.kt index e2636c2e8..5b33a94a7 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/DateFormats.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/DateFormats.kt @@ -5,6 +5,8 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.format +import kotlinx.datetime.format.MonthNames +import kotlinx.datetime.format.Padding import kotlinx.datetime.format.char import kotlinx.datetime.toInstant import ooniprobe.composeapp.generated.resources.Common_Ago @@ -17,6 +19,7 @@ import ooniprobe.composeapp.generated.resources.Common_Seconds_Abbreviated import ooniprobe.composeapp.generated.resources.Res import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource +import org.ooni.probe.shared.stringMonthArrayResource import org.ooni.probe.shared.today import kotlin.time.Clock import kotlin.time.Duration @@ -59,6 +62,19 @@ fun LocalDateTime.longFormat(): String = format(longDateTimeFormat) fun LocalDateTime.logFormat(): String = format(logDateTimeFormat) +@Composable +fun LocalDateTime.articleFormat(): String { + val monthNames = stringMonthArrayResource() + return LocalDateTime + .Format { + day(Padding.NONE) + char(' ') + monthName(MonthNames(monthNames)) + char(' ') + year() + }.format(this) +} + @Composable fun Duration.format(abbreviated: Boolean = true): String = toComponents { hours, minutes, seconds, _ -> diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/WebViewProgressIndicator.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/WebViewProgressIndicator.kt new file mode 100644 index 000000000..4ec428788 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/WebViewProgressIndicator.kt @@ -0,0 +1,40 @@ +package org.ooni.probe.ui.shared + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun BoxScope.WebViewProgressIndicator(progress: Float) { + val progressColor = MaterialTheme.colorScheme.onPrimary + val progressTrackColor = Color.Transparent + val progressModifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(bottom = 2.dp) + .height(2.dp) + + if (progress == 0f) { + LinearProgressIndicator( + color = progressColor, + trackColor = progressTrackColor, + modifier = progressModifier, + ) + } else { + LinearProgressIndicator( + progress = { progress }, + color = progressColor, + trackColor = progressTrackColor, + drawStopIndicator = {}, + modifier = progressModifier, + ) + } +} diff --git a/composeApp/src/commonMain/sqldelight/migrations/15.sqm b/composeApp/src/commonMain/sqldelight/migrations/15.sqm new file mode 100644 index 000000000..881eb05b4 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/migrations/15.sqm @@ -0,0 +1,7 @@ +CREATE TABLE Article( + url TEXT PRIMARY KEY, + title TEXT NOT NULL, + source TEXT NOT NULL, + description TEXT, + time INTEGER NOT NULL +); diff --git a/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Article.sq b/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Article.sq new file mode 100644 index 000000000..357b64b91 --- /dev/null +++ b/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Article.sq @@ -0,0 +1,23 @@ +CREATE TABLE Article( + url TEXT PRIMARY KEY, + title TEXT NOT NULL, + source TEXT NOT NULL, + description TEXT, + time INTEGER NOT NULL +); + +insertOrReplace: +INSERT OR REPLACE INTO Article ( + url, + title, + description, + source, + time +) VALUES (?,?,?,?,?); + +selectAll: +SELECT * FROM Article ORDER BY time DESC; + +deleteExceptUrls: +DELETE FROM Article WHERE Article.url NOT IN ?; + diff --git a/composeApp/src/dwMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt b/composeApp/src/dwMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt index b66e33245..1ccd247f5 100644 --- a/composeApp/src/dwMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt +++ b/composeApp/src/dwMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt @@ -15,4 +15,5 @@ object OrganizationConfig : OrganizationConfigInterface { ) override val hasWebsitesDescriptor = false override val donateUrl = null + override val hasOoniNews = false } diff --git a/composeApp/src/ooniMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt b/composeApp/src/ooniMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt index c66b82a69..fe67b3b19 100644 --- a/composeApp/src/ooniMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt +++ b/composeApp/src/ooniMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt @@ -17,4 +17,5 @@ object OrganizationConfig : OrganizationConfigInterface { ) override val hasWebsitesDescriptor = true override val donateUrl = "https://ooni.org/donate" + override val hasOoniNews = true } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 702f0e6b7..bc5d5c207 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,8 @@ javafx = { id = "org.openjfx.javafxplugin", version = "0.1.0" } [libraries] # Kotlin -kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.9.0" } +kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.9.0" } +kotlin-serialization-xml = { module = "io.github.pdvrieze.xmlutil:serialization", version = "0.91.2" } kotlin-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.7.1" } # Java @@ -111,7 +112,8 @@ desktop-oonimkall = { module = "org.ooni:oonimkall", version = "3.27.0-desktop" [bundles] kotlin = [ - "kotlin-serialization", + "kotlin-serialization-json", + "kotlin-serialization-xml", "kotlin-datetime", ] ui = [ From 14ca5f94ed5e286cd060878ebc8d443d06233a04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Wed, 22 Oct 2025 15:44:13 +0100 Subject: [PATCH 07/21] New dashboard tests --- .../screenshots/AutomateScreenshotsTest.kt | 58 ++++++++++++++----- .../ooni/probe/uitesting/RunningTestsTest.kt | 1 - .../uitesting/helpers/StateTestHelpers.kt | 2 +- .../ooni/probe/ui/articles/ArticlesScreen.kt | 2 +- .../repositories/ArticleRepositoryTest.kt | 42 ++++++++++++++ .../probe/domain/articles/GetFindingsTest.kt | 31 ++++++++++ .../probe/domain/articles/GetRssFeedTest.kt | 32 ++++++++++ .../probe/domain/results/GetLastRunTest.kt | 57 ++++++++++++++++++ .../testing/factories/ArticleModelFactory.kt | 24 ++++++++ .../testing/factories/ResultModelFactory.kt | 27 ++++++++- 10 files changed, 258 insertions(+), 18 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/org/ooni/probe/data/repositories/ArticleRepositoryTest.kt create mode 100644 composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetFindingsTest.kt create mode 100644 composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetRssFeedTest.kt create mode 100644 composeApp/src/commonTest/kotlin/org/ooni/probe/domain/results/GetLastRunTest.kt create mode 100644 composeApp/src/commonTest/kotlin/org/ooni/testing/factories/ArticleModelFactory.kt diff --git a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/screenshots/AutomateScreenshotsTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/screenshots/AutomateScreenshotsTest.kt index 4122af056..1b2694b96 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/screenshots/AutomateScreenshotsTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/screenshots/AutomateScreenshotsTest.kt @@ -29,17 +29,19 @@ import ooniprobe.composeapp.generated.resources.Res import ooniprobe.composeapp.generated.resources.Settings_About_Label import ooniprobe.composeapp.generated.resources.Settings_Advanced_Label import ooniprobe.composeapp.generated.resources.Settings_Privacy_Label -import ooniprobe.composeapp.generated.resources.Settings_Proxy_Enabled import ooniprobe.composeapp.generated.resources.Settings_Proxy_Label +import ooniprobe.composeapp.generated.resources.Settings_Proxy_Psiphon import ooniprobe.composeapp.generated.resources.Settings_Sharing_UploadResults_Description import ooniprobe.composeapp.generated.resources.Settings_TestOptions_Label import ooniprobe.composeapp.generated.resources.Settings_Title import ooniprobe.composeapp.generated.resources.Settings_Websites_Categories_Label import ooniprobe.composeapp.generated.resources.Settings_Websites_CustomURL_Title +import ooniprobe.composeapp.generated.resources.TestResults import ooniprobe.composeapp.generated.resources.TestResults_Overview_Tab_Label import ooniprobe.composeapp.generated.resources.Test_Dash_Fullname import ooniprobe.composeapp.generated.resources.Test_Performance_Fullname import ooniprobe.composeapp.generated.resources.Test_Websites_Fullname +import ooniprobe.composeapp.generated.resources.Tests_Title import ooniprobe.composeapp.generated.resources.app_name import org.junit.AfterClass import org.junit.Before @@ -106,6 +108,7 @@ class AutomateScreenshotsTest { runTest { if (!isOoni) return@runTest preferences.setValueByKey(SettingsKey.FIRST_RUN, true) + preferences.setValueByKey(SettingsKey.TESTS_MOVED_NOTICE, true) start() with(compose) { @@ -153,6 +156,32 @@ class AutomateScreenshotsTest { } } + @Test + fun tests() = + runTest { + if (!isOoni) return@runTest + skipOnboarding() + defaultSettings() + start() + + with(compose) { + wait { onNodeWithContentDescription(Res.string.app_name).isDisplayed() } + + wait(timeout = 30.seconds) { + onNodeWithText(Res.string.Dashboard_Progress_UpdateLink_Label) + .isNotDisplayed() + } + + clickOnText(Res.string.Tests_Title) + + wait { + onNodeWithText(Res.string.Test_Websites_Fullname).isDisplayed() + } + Screengrab.screenshot("2_" + locale()) + Screengrab.screenshot("22-tests") + } + } + @Test fun runTests() = runTest { @@ -257,17 +286,15 @@ class AutomateScreenshotsTest { // back clickOnContentDescription(Res.string.Common_Back) - wait { onNodeWithText(Res.string.Settings_About_Label).isDisplayed() } clickOnText(Res.string.Settings_Proxy_Label) - wait { onNodeWithText(Res.string.Settings_Proxy_Enabled).isDisplayed() } + wait { onNodeWithText(Res.string.Settings_Proxy_Psiphon).isDisplayed() } Screengrab.screenshot("14-proxy") // back clickOnContentDescription(Res.string.Common_Back) - wait { onNodeWithText(Res.string.Settings_About_Label).isDisplayed() } clickOnText(Res.string.Settings_Advanced_Label) @@ -298,14 +325,14 @@ class AutomateScreenshotsTest { with(compose) { wait { onNodeWithContentDescription(Res.string.app_name).isDisplayed() } - clickOnText(Res.string.TestResults_Overview_Tab_Label) + clickOnText(Res.string.TestResults) wait { onNodeWithText(Res.string.Test_Websites_Fullname).isDisplayed() } Screengrab.screenshot("17-results") Thread.sleep(3000) - Screengrab.screenshot("2_" + locale()) + Screengrab.screenshot("3_" + locale()) clickOnText(Res.string.Test_Websites_Fullname) @@ -320,7 +347,7 @@ class AutomateScreenshotsTest { checkTextAnywhereInsideWebView("https://z-lib.org/") Screengrab.screenshot("19-website-measurement-anomaly") - Screengrab.screenshot("3_" + locale()) + Screengrab.screenshot("4_" + locale()) clickOnContentDescription(Res.string.Common_Back) wait { onNodeWithText(Res.string.Test_Websites_Fullname).isDisplayed() } @@ -335,7 +362,7 @@ class AutomateScreenshotsTest { Screengrab.screenshot("20-dash-measurement") Thread.sleep(3000) - Screengrab.screenshot("4_" + locale()) + Screengrab.screenshot("5_" + locale()) } } @@ -353,12 +380,13 @@ class AutomateScreenshotsTest { .isNotDisplayed() } + clickOnText(Res.string.Tests_Title) clickOnText(Res.string.Test_Websites_Fullname) wait { onNodeWithText(Res.string.Test_Websites_Fullname).isDisplayed() } clickOnText(Res.string.Dashboard_Overview_ChooseWebsites) wait { onNodeWithText(Res.string.Settings_Websites_CustomURL_Title).isDisplayed() } Screengrab.screenshot("21-choose-websites") - Screengrab.screenshot("5_" + locale()) + Screengrab.screenshot("6_" + locale()) } } @@ -387,6 +415,10 @@ class AutomateScreenshotsTest { Screengrab.screenshot("1_${locale()}") + clickOnText(Res.string.Tests_Title) + wait { onNodeWithContentDescription(Res.string.Test_Websites_Fullname).isDisplayed() } + Screengrab.screenshot("2_${locale()}") + clickOnText(Res.string.Settings_Title) wait { onNodeWithContentDescription(Res.string.Settings_About_Label).isDisplayed() } @@ -394,7 +426,7 @@ class AutomateScreenshotsTest { clickOnText(Res.string.Settings_About_Label) wait { onNodeWithTag("AboutScreen").isDisplayed() } - Screengrab.screenshot("5_${locale()}") + Screengrab.screenshot("6_${locale()}") clickOnContentDescription(Res.string.Common_Back) @@ -403,7 +435,7 @@ class AutomateScreenshotsTest { wait { onNodeWithText(trustedName).isDisplayed() } Thread.sleep(3000) - Screengrab.screenshot("2_${locale()}") + Screengrab.screenshot("3_${locale()}") clickOnText(trustedName) @@ -411,13 +443,13 @@ class AutomateScreenshotsTest { // Screenshot was coming up empty, so we need to explicitly sleep here Thread.sleep(3000) - Screengrab.screenshot("3_${locale()}") + Screengrab.screenshot("4_${locale()}") clickOnText("https://www.dw.com") checkTextAnywhereInsideWebView("https://www.dw.com") - Screengrab.screenshot("4_${locale()}") + Screengrab.screenshot("5_${locale()}") } } diff --git a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/RunningTestsTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/RunningTestsTest.kt index dd8457d8a..2d8bb7e16 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/RunningTestsTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/RunningTestsTest.kt @@ -11,7 +11,6 @@ import ooniprobe.composeapp.generated.resources.Common_Expand import ooniprobe.composeapp.generated.resources.Dashboard_LastResults_SeeResults import ooniprobe.composeapp.generated.resources.Dashboard_RunTests_RunButton_Label import ooniprobe.composeapp.generated.resources.Dashboard_RunTests_SelectNone -import ooniprobe.composeapp.generated.resources.Dashboard_LastResults_SeeResults import ooniprobe.composeapp.generated.resources.Measurement_Title import ooniprobe.composeapp.generated.resources.OONIRun_Run import ooniprobe.composeapp.generated.resources.Res diff --git a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/StateTestHelpers.kt b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/StateTestHelpers.kt index f4a584b54..bc1f6a841 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/StateTestHelpers.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/StateTestHelpers.kt @@ -8,7 +8,7 @@ suspend fun skipOnboarding() { listOf( SettingsKey.FIRST_RUN to false, SettingsKey.TESTS_MOVED_NOTICE to true, - ) + ), ) } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesScreen.kt index 6bebe3f0e..764dd638f 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesScreen.kt @@ -77,7 +77,7 @@ fun ArticlesScreen( ), state = lazyListState, ) { - items(state.articles, key = { it.url }) { article -> + items(state.articles, key = { it.url.value }) { article -> ArticleCell( article = article, onClick = { onEvent(ArticlesViewModel.Event.ArticleClicked(article)) }, diff --git a/composeApp/src/commonTest/kotlin/org/ooni/probe/data/repositories/ArticleRepositoryTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/data/repositories/ArticleRepositoryTest.kt new file mode 100644 index 000000000..2c3cded9a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/data/repositories/ArticleRepositoryTest.kt @@ -0,0 +1,42 @@ +package org.ooni.probe.data.repositories + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.ooni.probe.di.Dependencies +import org.ooni.testing.createTestDatabaseDriver +import org.ooni.testing.factories.ArticleModelFactory +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ArticleRepositoryTest { + private lateinit var subject: ArticleRepository + + @BeforeTest + fun before() { + subject = ArticleRepository( + database = Dependencies.buildDatabase(::createTestDatabaseDriver), + backgroundContext = Dispatchers.Default, + ) + } + + @Test + fun refreshAndList() = + runTest { + val articleToRemove = ArticleModelFactory.build() + val articleToKeep = ArticleModelFactory.build() + val articleToAdd = ArticleModelFactory.build() + subject.refresh(listOf(articleToRemove, articleToKeep)) + subject.refresh(listOf(articleToKeep, articleToAdd)) + + val result = subject.list().first() + + assertEquals(2, result.size) + assertFalse(result.contains(articleToRemove)) + assertTrue(result.contains(articleToKeep)) + assertTrue(result.contains(articleToAdd)) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetFindingsTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetFindingsTest.kt new file mode 100644 index 000000000..627bdd1d1 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetFindingsTest.kt @@ -0,0 +1,31 @@ +package org.ooni.probe.domain.articles + +import kotlinx.coroutines.test.runTest +import org.ooni.engine.models.Success +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GetFindingsTest { + @Test + fun invoke() = + runTest { + val subject = GetFindings( + httpDo = { _, _, _ -> Success(API_RESPONSE) }, + ) + + val articles = subject().get()!! + assertEquals(2, articles.size) + with(articles.first()) { + assertTrue(url.value.endsWith("8025203600")) + assertEquals("Indonesia blocked access to the Internet Archive", title) + assertEquals("This report shares OONI data on the blocking of the Internet Archive in Indonesia in May 2025.", description) + assertEquals(2025, time.year) + } + } + + companion object { + private const val API_RESPONSE = + "{\"incidents\":[{\"id\":\"8025203600\",\"email_address\":\"\",\"title\":\"Indonesia blocked access to the Internet Archive\",\"short_description\":\"This report shares OONI data on the blocking of the Internet Archive in Indonesia in May 2025.\",\"slug\":\"2025-indonesia-blocked-access-to-the-internet-archive\",\"start_time\":\"2025-05-26T00:00:00.000000Z\",\"create_time\":\"2025-06-13T07:35:49.000000Z\",\"update_time\":\"2025-06-13T07:35:49.000000Z\",\"end_time\":\"2025-05-29T00:00:00.000000Z\",\"reported_by\":\"Elizaveta Yachmeneva, Maria Xynou\",\"creator_account_id\":\"\",\"published\":true,\"event_type\":\"incident\",\"ASNs\":[23693,63859,24203,17451,136119,7713,18004,23951,139447],\"CCs\":[\"ID\"],\"themes\":[],\"tags\":[\"censorship\",\"archive.org\"],\"test_names\":[\"web_connectivity\"],\"domains\":[\"archive.org\"],\"links\":[],\"mine\":false},{\"id\":\"178720534001\",\"email_address\":\"\",\"title\":\"Malaysia blocked MalaysiaNow and website of former MP\",\"short_description\":\"This report shares OONI data on the blocking of news media outlet MalaysiaNow and of a website which belongs to a former Malaysian Member of Parliament (Wee Choo Keong). \",\"slug\":null,\"start_time\":\"2023-06-28T00:00:00.000000Z\",\"create_time\":\"2023-12-19T09:07:46.000000Z\",\"update_time\":\"2025-06-02T11:50:22.000000Z\",\"end_time\":\"2024-09-07T00:00:00.000000Z\",\"reported_by\":\"Maria Xynou\",\"creator_account_id\":\"\",\"published\":true,\"event_type\":\"incident\",\"ASNs\":[10030,4788,4818,9534,38466,45960,38322,4818],\"CCs\":[\"MY\"],\"themes\":[\"news_media\"],\"tags\":[\"censorship\",\"MalaysiaNow\",\"Wee Choo Keong\"],\"test_names\":[\"web_connectivity\"],\"domains\":[\"www.malaysianow.com\",\"weechookeong.com\"],\"links\":[],\"mine\":false}]}" + } +} diff --git a/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetRssFeedTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetRssFeedTest.kt new file mode 100644 index 000000000..498352a2a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetRssFeedTest.kt @@ -0,0 +1,32 @@ +package org.ooni.probe.domain.articles + +import kotlinx.coroutines.test.runTest +import org.ooni.engine.models.Success +import org.ooni.probe.data.models.ArticleModel +import kotlin.test.Test +import kotlin.test.assertEquals + +class GetRssFeedTest { + @Test + fun invoke() = + runTest { + val subject = GetRSSFeed( + httpDo = { _, _, _ -> Success(RSS_FEED) }, + url = "https://example.org", + source = ArticleModel.Source.Blog, + ) + + val articles = subject().get()!! + assertEquals(1, articles.size) + with(articles.first()) { + assertEquals("https://ooni.org/post/2025-gg-omg-village/", url.value) + assertEquals("Join us at the OMG Village at the Global Gathering 2025!", title) + assertEquals(2025, time.year) + } + } + + companion object { + private const val RSS_FEED = + "Blog posts on OONI: Open Observatory of Network Interferencehttps://ooni.org/blog/Recent content in Blog posts on OONI: Open Observatory of Network InterferenceHugoenJoin us at the OMG Village at the Global Gathering 2025!https://ooni.org/post/2025-gg-omg-village/Mon, 01 Sep 2025 00:00:00 +0000https://ooni.org/post/2025-gg-omg-village/<p>Are you attending the upcoming <a href=\"https://wiki.digitalrights.community/index.php?title=Global_Gathering_2025\">Global Gathering</a> event in Estoril, Portugal? Are you interested in investigating internet shutdowns and censorship, and curious to learn more about the tools and open datasets that support this work?</p>" + } +} diff --git a/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/results/GetLastRunTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/results/GetLastRunTest.kt new file mode 100644 index 000000000..a58db064b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/results/GetLastRunTest.kt @@ -0,0 +1,57 @@ +package org.ooni.probe.domain.results + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.ooni.testing.factories.ResultModelFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class GetLastRunTest { + @Test + fun nullWhenNoResults() = + runTest { + val subject = GetLastRun( + getLastResults = { _ -> flowOf(emptyList()) }, + getPreference = { _ -> flowOf(null) }, + ) + assertNull(subject().first()) + } + + @Test + fun nullWhenResultIsDismissed() = + runTest { + val result1 = ResultModelFactory.buildWithNetworkAndAggregates( + result = ResultModelFactory.build(descriptorName = "websites"), + ) + + val subject = GetLastRun( + getLastResults = { _ -> flowOf(listOf(result1)) }, + getPreference = { _ -> flowOf(result1.result.id?.value) }, + ) + + assertNull(subject().first()) + } + + @Test + fun doesNotRepeatDescriptors() = + runTest { + val result1 = ResultModelFactory.buildWithNetworkAndAggregates( + result = ResultModelFactory.build(descriptorName = "websites", isDone = true), + ) + val result2 = ResultModelFactory.buildWithNetworkAndAggregates( + result = ResultModelFactory.build(descriptorName = "websites", isDone = true), + ) + + val subject = GetLastRun( + getLastResults = { _ -> flowOf(listOf(result1, result2)) }, + getPreference = { _ -> flowOf(null) }, + ) + + val run = subject().first()!! + assertEquals(1, run.results.size) + assertTrue(run.results.contains(result1)) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/ooni/testing/factories/ArticleModelFactory.kt b/composeApp/src/commonTest/kotlin/org/ooni/testing/factories/ArticleModelFactory.kt new file mode 100644 index 000000000..5e5d607a0 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/ooni/testing/factories/ArticleModelFactory.kt @@ -0,0 +1,24 @@ +package org.ooni.testing.factories + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.atTime +import org.ooni.probe.data.models.ArticleModel +import org.ooni.probe.shared.today +import kotlin.random.Random + +object ArticleModelFactory { + fun build( + url: ArticleModel.Url = ArticleModel.Url("https://example.org/${Random.nextInt()}"), + title: String = "Title", + description: String? = null, + time: LocalDateTime = LocalDate.today().atTime(0, 0), + source: ArticleModel.Source = ArticleModel.Source.Blog, + ) = ArticleModel( + url = url, + title = title, + description = description, + time = time, + source = source, + ) +} diff --git a/composeApp/src/commonTest/kotlin/org/ooni/testing/factories/ResultModelFactory.kt b/composeApp/src/commonTest/kotlin/org/ooni/testing/factories/ResultModelFactory.kt index eb0595bde..5e0babb90 100644 --- a/composeApp/src/commonTest/kotlin/org/ooni/testing/factories/ResultModelFactory.kt +++ b/composeApp/src/commonTest/kotlin/org/ooni/testing/factories/ResultModelFactory.kt @@ -5,15 +5,19 @@ import kotlinx.datetime.LocalDateTime import kotlinx.datetime.atTime import org.ooni.engine.models.TaskOrigin import org.ooni.probe.data.models.InstalledTestDescriptorModel +import org.ooni.probe.data.models.MeasurementCounts import org.ooni.probe.data.models.NetworkModel import org.ooni.probe.data.models.ResultModel +import org.ooni.probe.data.models.ResultWithNetworkAndAggregates +import org.ooni.probe.shared.now import org.ooni.probe.shared.today +import kotlin.random.Random object ResultModelFactory { fun build( - id: ResultModel.Id? = ResultModel.Id(1234L), + id: ResultModel.Id? = ResultModel.Id(Random.nextLong()), descriptorName: String? = "websites", - startTime: LocalDateTime = LocalDate.today().atTime(0, 0), + startTime: LocalDateTime = LocalDateTime.nowWithoutNanoseconds(), isViewed: Boolean = false, isDone: Boolean = false, dataUsageUp: Long = 0, @@ -35,4 +39,23 @@ object ResultModelFactory { networkId = networkId, descriptorKey = descriptorKey, ) + + fun buildWithNetworkAndAggregates( + result: ResultModel = build(), + network: NetworkModel = NetworkModelFactory.build(), + measurementCounts: MeasurementCounts = MeasurementCounts(0, 0, 0), + allMeasurementsUploaded: Boolean = false, + anyMeasurementUploadFailed: Boolean = false, + ) = ResultWithNetworkAndAggregates( + result = result, + network = network, + measurementCounts = measurementCounts, + allMeasurementsUploaded = allMeasurementsUploaded, + anyMeasurementUploadFailed = anyMeasurementUploadFailed, + ) +} + +private fun LocalDateTime.Companion.nowWithoutNanoseconds(): LocalDateTime { + val now = LocalDateTime.now() + return LocalDate.today().atTime(now.hour, now.minute, now.second) } From 93f0b9b0837e291f346c4eedf49e66a54389cd87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Wed, 22 Oct 2025 17:13:48 +0100 Subject: [PATCH 08/21] Add article webview param and handle outside links --- .../org/ooni/probe/ui/shared/OoniWebView.android.kt | 7 ++++++- .../org/ooni/probe/ui/articles/ArticleScreen.kt | 1 + .../org/ooni/probe/ui/articles/ArticleViewModel.kt | 11 ++++++++++- .../kotlin/org/ooni/probe/ui/shared/OoniWebView.kt | 1 + .../org/ooni/probe/ui/shared/OoniWebView.desktop.kt | 5 ++++- .../org/ooni/probe/ui/shared/OoniWebView.ios.kt | 1 + 6 files changed, 23 insertions(+), 3 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.android.kt b/composeApp/src/androidMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.android.kt index ecbfb652c..0656a8a86 100644 --- a/composeApp/src/androidMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.android.kt +++ b/composeApp/src/androidMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.android.kt @@ -20,6 +20,7 @@ actual fun OoniWebView( controller: OoniWebViewController, modifier: Modifier, allowedDomains: List, + onDisallowedUrl: (String) -> Unit, ) { fun isRequestAllowed(request: WebResourceRequest) = allowedDomains.any { domain -> @@ -65,7 +66,11 @@ actual fun OoniWebView( override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest, - ) = !isRequestAllowed(request) + ): Boolean { + val isAllowed = isRequestAllowed(request) + if (!isAllowed) onDisallowedUrl(request.url.toString()) + return !isAllowed + } override fun onPageStarted( view: WebView, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleScreen.kt index e1c013664..672aa41ba 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleScreen.kt @@ -107,6 +107,7 @@ fun ArticleScreen( .fillMaxSize() .alpha(if (isFailure) 0f else 1f) .padding(WindowInsets.navigationBars.asPaddingValues()), + onDisallowedUrl = { onEvent(ArticleViewModel.Event.OutsideLinkClicked(it)) }, ) if (isFailure) { diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleViewModel.kt index 5e688f7dc..69db67886 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleViewModel.kt @@ -26,7 +26,7 @@ class ArticleViewModel( init { viewModelScope.launch { if (isWebViewAvailable()) { - _state.value = State.Show(url.value) + _state.value = State.Show(url.value + "?enable-embedded-view=true") } else { launchAction(PlatformAction.OpenUrl(url.value)) onBack() @@ -42,6 +42,11 @@ class ArticleViewModel( .filterIsInstance() .onEach { launchAction(PlatformAction.Share(url.value)) } .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { launchAction(PlatformAction.OpenUrl(it.url)) } + .launchIn(viewModelScope) } fun onEvent(event: Event) { @@ -60,5 +65,9 @@ class ArticleViewModel( data object BackClicked : Event data object ShareUrl : Event + + data class OutsideLinkClicked( + val url: String, + ) : Event } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.kt index 097736b53..61aed323c 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.kt @@ -13,6 +13,7 @@ expect fun OoniWebView( controller: OoniWebViewController, modifier: Modifier = Modifier, allowedDomains: List = listOf("ooni.org"), + onDisallowedUrl: (String) -> Unit = {}, ) class OoniWebViewController { diff --git a/composeApp/src/desktopMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.desktop.kt b/composeApp/src/desktopMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.desktop.kt index 90f982ff1..b979528f5 100644 --- a/composeApp/src/desktopMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.desktop.kt @@ -17,6 +17,7 @@ actual fun OoniWebView( controller: OoniWebViewController, modifier: Modifier, allowedDomains: List, + onDisallowedUrl: (String) -> Unit, ) { val event = controller.rememberNextEvent() @@ -68,7 +69,9 @@ actual fun OoniWebView( } if (!allowed) { - engine.load("about:blank") + engine.history.go(-1) // go back + // engine.load("about:blank") + onDisallowedUrl(newLocation) } } catch (e: Exception) { // Invalid URL, ignore diff --git a/composeApp/src/iosMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.ios.kt b/composeApp/src/iosMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.ios.kt index 29a38672d..47eb58421 100644 --- a/composeApp/src/iosMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.ios.kt +++ b/composeApp/src/iosMain/kotlin/org/ooni/probe/ui/shared/OoniWebView.ios.kt @@ -15,6 +15,7 @@ actual fun OoniWebView( controller: OoniWebViewController, modifier: Modifier, allowedDomains: List, + onDisallowedUrl: (String) -> Unit, // TODO ) { val event = controller.rememberNextEvent() val state = rememberWebViewState("about:blank") From 80b9d7e318b9559376e72676dd4928c3fd2d6a77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Wed, 22 Oct 2025 18:22:27 +0100 Subject: [PATCH 09/21] Address code review comments --- .../kotlin/org/ooni/probe/di/Dependencies.kt | 9 +- .../ooni/probe/domain/articles/GetArticles.kt | 10 -- .../ooni/probe/domain/articles/GetFindings.kt | 11 +- .../probe/domain/articles/RefreshArticles.kt | 4 +- .../probe/ui/dashboard/DashboardScreen.kt | 2 +- .../probe/domain/articles/GetFindingsTest.kt | 102 +++++++++++++++++- 6 files changed, 108 insertions(+), 30 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetArticles.kt 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 91c585386..cd1ee3ab0 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -74,7 +74,6 @@ import org.ooni.probe.domain.ShouldShowVpnWarning import org.ooni.probe.domain.UploadMissingMeasurements import org.ooni.probe.domain.appreview.MarkAppReviewAsShown import org.ooni.probe.domain.appreview.ShouldShowAppReview -import org.ooni.probe.domain.articles.GetArticles import org.ooni.probe.domain.articles.RefreshArticles import org.ooni.probe.domain.descriptors.AcceptDescriptorUpdate import org.ooni.probe.domain.descriptors.BootstrapTestDescriptors @@ -324,9 +323,6 @@ class Dependencies( updateState = descriptorUpdateStateManager::update, ) } - private val getArticles by lazy { - GetArticles(articleRepository::list) - } val getAutoRunSettings by lazy { GetAutoRunSettings(preferenceRepository::allSettings) } private val getAutoRunSpecification by lazy { GetAutoRunSpecification(getTestDescriptors::latest, preferenceRepository) @@ -501,6 +497,7 @@ class Dependencies( val refreshArticles by lazy { RefreshArticles( httpDo = engine::httpDo, + json = json, refreshArticlesInDatabase = articleRepository::refresh, ) } @@ -599,7 +596,7 @@ class Dependencies( ) = ArticlesViewModel( onBack = onBack, goToArticle = goToArticle, - getArticles = getArticles::invoke, + getArticles = articleRepository::list, refreshArticles = refreshArticles::invoke, canPullToRefresh = platformInfo.canPullToRefresh, ) @@ -645,7 +642,7 @@ class Dependencies( getPreference = preferenceRepository::getValueByKey, setPreference = preferenceRepository::setValueByKey, getStats = getStats::invoke, - getArticles = getArticles::invoke, + getArticles = articleRepository::list, batteryOptimization = batteryOptimization, ) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetArticles.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetArticles.kt deleted file mode 100644 index 6ca7a86aa..000000000 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetArticles.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.ooni.probe.domain.articles - -import kotlinx.coroutines.flow.Flow -import org.ooni.probe.data.models.ArticleModel - -class GetArticles( - val getArticles: () -> Flow>, -) { - operator fun invoke() = getArticles() -} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt index 6e8b4ba51..40c773d24 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt @@ -16,6 +16,7 @@ import kotlin.time.Instant class GetFindings( val httpDo: suspend (String, String, TaskOrigin) -> Result, + val json: Json, ) : RefreshArticles.Source { override suspend operator fun invoke(): Result, Exception> { return httpDo("GET", "https://api.ooni.org/api/v1/incidents/search", TaskOrigin.OoniRun) @@ -24,7 +25,7 @@ class GetFindings( if (response.isNullOrBlank()) return@flatMap Failure(Exception("Empty response")) val wrapper = try { - Json.decodeFromString(response) + json.decodeFromString(response) } catch (e: Exception) { return@flatMap Failure(Exception("Could not parse indidents API response", e)) } @@ -48,14 +49,6 @@ class GetFindings( @OptIn(FormatStringsInDatetimeFormats::class) private fun String.toLocalDateTime(): LocalDateTime? = Instant.parse(this).toLocalDateTime() - companion object { - private val Json by lazy { - Json { - ignoreUnknownKeys = true - } - } - } - @Serializable data class Wrapper( @SerialName("incidents") diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt index 297dbdb0e..5bec3a6e8 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.json.Json import org.ooni.engine.Engine.MkException import org.ooni.engine.models.Failure import org.ooni.engine.models.Result @@ -13,6 +14,7 @@ import org.ooni.probe.data.models.ArticleModel class RefreshArticles( val httpDo: suspend (String, String, TaskOrigin) -> Result, + val json: Json, val refreshArticlesInDatabase: suspend (List) -> Unit, ) { fun interface Source { @@ -25,7 +27,7 @@ class RefreshArticles( val sources = listOf( GetRSSFeed(httpDo, "https://ooni.org/blog/index.xml", ArticleModel.Source.Blog), GetRSSFeed(httpDo, "https://ooni.org/reports/index.xml", ArticleModel.Source.Report), - GetFindings(httpDo), + GetFindings(httpDo, json), ) val responses = sources 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 201dcef18..61fdfbb47 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 @@ -469,7 +469,7 @@ private fun ArticlesSection( showReadMore: Boolean, onEvent: (DashboardViewModel.Event) -> Unit, ) { - if (!OrganizationConfig.hasOoniNews) return + if (!OrganizationConfig.hasOoniNews || articles.isEmpty()) return HorizontalDivider( thickness = Dp.Hairline, diff --git a/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetFindingsTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetFindingsTest.kt index 627bdd1d1..6374c7f56 100644 --- a/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetFindingsTest.kt +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetFindingsTest.kt @@ -2,6 +2,7 @@ package org.ooni.probe.domain.articles import kotlinx.coroutines.test.runTest import org.ooni.engine.models.Success +import org.ooni.probe.di.Dependencies import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -12,6 +13,7 @@ class GetFindingsTest { runTest { val subject = GetFindings( httpDo = { _, _, _ -> Success(API_RESPONSE) }, + json = Dependencies.buildJson(), ) val articles = subject().get()!! @@ -19,13 +21,107 @@ class GetFindingsTest { with(articles.first()) { assertTrue(url.value.endsWith("8025203600")) assertEquals("Indonesia blocked access to the Internet Archive", title) - assertEquals("This report shares OONI data on the blocking of the Internet Archive in Indonesia in May 2025.", description) + assertEquals( + "This report shares OONI data on the blocking of the Internet Archive in Indonesia in May 2025.", + description, + ) assertEquals(2025, time.year) } } companion object { - private const val API_RESPONSE = - "{\"incidents\":[{\"id\":\"8025203600\",\"email_address\":\"\",\"title\":\"Indonesia blocked access to the Internet Archive\",\"short_description\":\"This report shares OONI data on the blocking of the Internet Archive in Indonesia in May 2025.\",\"slug\":\"2025-indonesia-blocked-access-to-the-internet-archive\",\"start_time\":\"2025-05-26T00:00:00.000000Z\",\"create_time\":\"2025-06-13T07:35:49.000000Z\",\"update_time\":\"2025-06-13T07:35:49.000000Z\",\"end_time\":\"2025-05-29T00:00:00.000000Z\",\"reported_by\":\"Elizaveta Yachmeneva, Maria Xynou\",\"creator_account_id\":\"\",\"published\":true,\"event_type\":\"incident\",\"ASNs\":[23693,63859,24203,17451,136119,7713,18004,23951,139447],\"CCs\":[\"ID\"],\"themes\":[],\"tags\":[\"censorship\",\"archive.org\"],\"test_names\":[\"web_connectivity\"],\"domains\":[\"archive.org\"],\"links\":[],\"mine\":false},{\"id\":\"178720534001\",\"email_address\":\"\",\"title\":\"Malaysia blocked MalaysiaNow and website of former MP\",\"short_description\":\"This report shares OONI data on the blocking of news media outlet MalaysiaNow and of a website which belongs to a former Malaysian Member of Parliament (Wee Choo Keong). \",\"slug\":null,\"start_time\":\"2023-06-28T00:00:00.000000Z\",\"create_time\":\"2023-12-19T09:07:46.000000Z\",\"update_time\":\"2025-06-02T11:50:22.000000Z\",\"end_time\":\"2024-09-07T00:00:00.000000Z\",\"reported_by\":\"Maria Xynou\",\"creator_account_id\":\"\",\"published\":true,\"event_type\":\"incident\",\"ASNs\":[10030,4788,4818,9534,38466,45960,38322,4818],\"CCs\":[\"MY\"],\"themes\":[\"news_media\"],\"tags\":[\"censorship\",\"MalaysiaNow\",\"Wee Choo Keong\"],\"test_names\":[\"web_connectivity\"],\"domains\":[\"www.malaysianow.com\",\"weechookeong.com\"],\"links\":[],\"mine\":false}]}" + private val API_RESPONSE = """ + { + "incidents": [ + { + "id": "8025203600", + "email_address": "", + "title": "Indonesia blocked access to the Internet Archive", + "short_description": "This report shares OONI data on the blocking of the Internet Archive in Indonesia in May 2025.", + "slug": "2025-indonesia-blocked-access-to-the-internet-archive", + "start_time": "2025-05-26T00:00:00.000000Z", + "create_time": "2025-06-13T07:35:49.000000Z", + "update_time": "2025-06-13T07:35:49.000000Z", + "end_time": "2025-05-29T00:00:00.000000Z", + "reported_by": "Elizaveta Yachmeneva, Maria Xynou", + "creator_account_id": "", + "published": true, + "event_type": "incident", + "ASNs": [ + 23693, + 63859, + 24203, + 17451, + 136119, + 7713, + 18004, + 23951, + 139447 + ], + "CCs": [ + "ID" + ], + "themes": [], + "tags": [ + "censorship", + "archive.org" + ], + "test_names": [ + "web_connectivity" + ], + "domains": [ + "archive.org" + ], + "links": [], + "mine": false + }, + { + "id": "178720534001", + "email_address": "", + "title": "Malaysia blocked MalaysiaNow and website of former MP", + "short_description": "This report shares OONI data on the blocking of news media outlet MalaysiaNow and of a website which belongs to a former Malaysian Member of Parliament (Wee Choo Keong). ", + "slug": null, + "start_time": "2023-06-28T00:00:00.000000Z", + "create_time": "2023-12-19T09:07:46.000000Z", + "update_time": "2025-06-02T11:50:22.000000Z", + "end_time": "2024-09-07T00:00:00.000000Z", + "reported_by": "Maria Xynou", + "creator_account_id": "", + "published": true, + "event_type": "incident", + "ASNs": [ + 10030, + 4788, + 4818, + 9534, + 38466, + 45960, + 38322, + 4818 + ], + "CCs": [ + "MY" + ], + "themes": [ + "news_media" + ], + "tags": [ + "censorship", + "MalaysiaNow", + "Wee Choo Keong" + ], + "test_names": [ + "web_connectivity" + ], + "domains": [ + "www.malaysianow.com", + "weechookeong.com" + ], + "links": [], + "mine": false + } + ] + } + """.trimIndent() } } From 88c893b629e567633b62c233c368e4050b1e296d Mon Sep 17 00:00:00 2001 From: Norbel AMBANUMBEN Date: Mon, 27 Oct 2025 14:01:12 +0000 Subject: [PATCH 10/21] fix: cast Screen and link network factory class (#969) --- .../kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt | 1 + .../kotlin/org/ooni/testing/factories/NetworkModelFactory.kt | 1 + 2 files changed, 2 insertions(+) create mode 120000 composeApp/src/iosMain/kotlin/org/ooni/testing/factories/NetworkModelFactory.kt diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt index 6950f33ae..c32c47f3e 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt @@ -51,6 +51,7 @@ fun BottomNavigationBar( ) { MAIN_NAVIGATION_SCREENS.forEach { screen -> val isCurrentScreen = entry?.destination?.hasRoute(screen::class) == true + val screen = screen as Screen NavigationBarItem( icon = { NavigationBadgeBox( diff --git a/composeApp/src/iosMain/kotlin/org/ooni/testing/factories/NetworkModelFactory.kt b/composeApp/src/iosMain/kotlin/org/ooni/testing/factories/NetworkModelFactory.kt new file mode 120000 index 000000000..358c96a09 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/ooni/testing/factories/NetworkModelFactory.kt @@ -0,0 +1 @@ +../../../../../../commonTest/kotlin/org/ooni/testing/factories/NetworkModelFactory.kt \ No newline at end of file From 78926f4ba7ea931c12669c6a1f4deb7674da7cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Mon, 27 Oct 2025 16:54:33 +0000 Subject: [PATCH 11/21] Refresh articles only once per day --- .../org/ooni/probe/uitesting/DashboardTest.kt | 59 +++++++++++++++ .../ooni/probe/uitesting/DescriptorsTest.kt | 2 + .../ooni/probe/uitesting/OnboardingTest.kt | 2 + .../ooni/probe/uitesting/RunningTestsTest.kt | 2 + .../org/ooni/probe/uitesting/SettingsTest.kt | 2 + .../ooni/probe/uitesting/UploadResultTest.kt | 2 + .../uitesting/helpers/StateTestHelpers.kt | 5 ++ .../org/ooni/probe/data/models/SettingsKey.kt | 1 + .../data/repositories/PreferenceRepository.kt | 1 + .../kotlin/org/ooni/probe/di/Dependencies.kt | 19 ++++- .../probe/domain/articles/RefreshArticles.kt | 38 ++++++---- .../domain/articles/RefreshArticlesTest.kt | 73 +++++++++++++++++++ 12 files changed, 189 insertions(+), 17 deletions(-) create mode 100644 composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/DashboardTest.kt create mode 100644 composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/RefreshArticlesTest.kt diff --git a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/DashboardTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/DashboardTest.kt new file mode 100644 index 000000000..0c5d49325 --- /dev/null +++ b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/DashboardTest.kt @@ -0,0 +1,59 @@ +package org.ooni.probe.uitesting + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.test.runTest +import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Title +import ooniprobe.composeapp.generated.resources.Dashboard_TestsMoved_Description +import ooniprobe.composeapp.generated.resources.Res +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.ooni.probe.data.models.SettingsKey +import org.ooni.probe.uitesting.helpers.disableRefreshArticles +import org.ooni.probe.uitesting.helpers.onNodeWithText +import org.ooni.probe.uitesting.helpers.preferences +import org.ooni.probe.uitesting.helpers.skipOnboarding +import org.ooni.probe.uitesting.helpers.start +import org.ooni.probe.uitesting.helpers.wait +import kotlin.time.Duration.Companion.minutes + +@RunWith(AndroidJUnit4::class) +class DashboardTest { + @get:Rule + val compose = createEmptyComposeRule() + + @Before + fun setUp() = + runTest { + skipOnboarding() + } + + @Test + fun testsMovedNotice() = + runTest { + disableRefreshArticles() + preferences.setValueByKey(SettingsKey.TESTS_MOVED_NOTICE, false) + start() + + with(compose) { + onNodeWithText(Res.string.Dashboard_TestsMoved_Description).assertIsDisplayed() + } + } + + @Test + fun news() = + runTest { + preferences.setValueByKey(SettingsKey.LAST_ARTICLES_REFRESH, 0L) + start() + + with(compose) { + wait(timeout = 1.minutes) { + onNodeWithText(Res.string.Dashboard_Articles_Title).isDisplayed() + } + } + } +} diff --git a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/DescriptorsTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/DescriptorsTest.kt index 13232d183..7a665e178 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/DescriptorsTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/DescriptorsTest.kt @@ -39,6 +39,7 @@ import org.ooni.probe.MainActivity import org.ooni.probe.uitesting.helpers.clickOnText import org.ooni.probe.uitesting.helpers.context import org.ooni.probe.uitesting.helpers.dependencies +import org.ooni.probe.uitesting.helpers.disableRefreshArticles import org.ooni.probe.uitesting.helpers.isNewsMediaScan import org.ooni.probe.uitesting.helpers.onAllNodesWithText import org.ooni.probe.uitesting.helpers.onNodeWithText @@ -57,6 +58,7 @@ class DescriptorsTest { fun setUp() = runTest { skipOnboarding() + disableRefreshArticles() } @Test diff --git a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/OnboardingTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/OnboardingTest.kt index 0a3072532..6ff4cc974 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/OnboardingTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/OnboardingTest.kt @@ -26,6 +26,7 @@ import org.ooni.probe.data.models.SettingsKey import org.ooni.probe.uitesting.helpers.clickOnTag import org.ooni.probe.uitesting.helpers.clickOnText import org.ooni.probe.uitesting.helpers.dependencies +import org.ooni.probe.uitesting.helpers.disableRefreshArticles import org.ooni.probe.uitesting.helpers.isCrashReportingEnabled import org.ooni.probe.uitesting.helpers.onNodeWithContentDescription import org.ooni.probe.uitesting.helpers.onNodeWithText @@ -41,6 +42,7 @@ class OnboardingTest { @Before fun setUp() = runTest { + disableRefreshArticles() start() } diff --git a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/RunningTestsTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/RunningTestsTest.kt index 2d8bb7e16..ac1dfc0dc 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/RunningTestsTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/RunningTestsTest.kt @@ -32,6 +32,7 @@ import org.ooni.probe.uitesting.helpers.checkSummaryInsideWebView import org.ooni.probe.uitesting.helpers.checkTextAnywhereInsideWebView import org.ooni.probe.uitesting.helpers.clickOnContentDescription import org.ooni.probe.uitesting.helpers.clickOnText +import org.ooni.probe.uitesting.helpers.disableRefreshArticles import org.ooni.probe.uitesting.helpers.isNewsMediaScan import org.ooni.probe.uitesting.helpers.isOoni import org.ooni.probe.uitesting.helpers.onNodeWithText @@ -50,6 +51,7 @@ class RunningTestsTest { fun setUp() = runTest { skipOnboarding() + disableRefreshArticles() preferences.setValueByKey(SettingsKey.UPLOAD_RESULTS, true) start() } diff --git a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/SettingsTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/SettingsTest.kt index 5e0b16f25..b34fca69f 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/SettingsTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/SettingsTest.kt @@ -39,6 +39,7 @@ import org.ooni.probe.data.models.ProxyOption import org.ooni.probe.data.models.SettingsKey import org.ooni.probe.uitesting.helpers.clickOnContentDescription import org.ooni.probe.uitesting.helpers.clickOnText +import org.ooni.probe.uitesting.helpers.disableRefreshArticles import org.ooni.probe.uitesting.helpers.isCrashReportingEnabled import org.ooni.probe.uitesting.helpers.isOoni import org.ooni.probe.uitesting.helpers.preferences @@ -55,6 +56,7 @@ class SettingsTest { fun setUp() = runTest { skipOnboarding() + disableRefreshArticles() start() } diff --git a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/UploadResultTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/UploadResultTest.kt index 46592b4e6..f4af63337 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/UploadResultTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/UploadResultTest.kt @@ -26,6 +26,7 @@ import org.junit.runner.RunWith import org.ooni.probe.data.models.SettingsKey import org.ooni.probe.uitesting.helpers.checkSummaryInsideWebView import org.ooni.probe.uitesting.helpers.clickOnText +import org.ooni.probe.uitesting.helpers.disableRefreshArticles import org.ooni.probe.uitesting.helpers.isNewsMediaScan import org.ooni.probe.uitesting.helpers.isOoni import org.ooni.probe.uitesting.helpers.onNodeWithText @@ -45,6 +46,7 @@ class UploadResultTest { fun setUp() = runTest { skipOnboarding() + disableRefreshArticles() preferences.setValueByKey(SettingsKey.UPLOAD_RESULTS, false) start() } diff --git a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/StateTestHelpers.kt b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/StateTestHelpers.kt index bc1f6a841..98113819b 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/StateTestHelpers.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/org/ooni/probe/uitesting/helpers/StateTestHelpers.kt @@ -2,6 +2,7 @@ package org.ooni.probe.uitesting.helpers import org.ooni.probe.data.models.SettingsKey import org.ooni.probe.domain.organizationPreferenceDefaults +import kotlin.time.Clock suspend fun skipOnboarding() { preferences.setValuesByKey( @@ -12,6 +13,10 @@ suspend fun skipOnboarding() { ) } +suspend fun disableRefreshArticles() { + preferences.setValueByKey(SettingsKey.LAST_ARTICLES_REFRESH, Clock.System.now().epochSeconds) +} + suspend fun defaultSettings() { preferences.setValuesByKey( listOf( diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/SettingsKey.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/SettingsKey.kt index b3d4670b7..4428e8b5b 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/SettingsKey.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/SettingsKey.kt @@ -69,6 +69,7 @@ enum class SettingsKey( DESCRIPTOR_SECTIONS_COLLAPSED("descriptor_sections_collapsed"), LAST_RUN_DISMISSED("last_run_dismissed"), TESTS_MOVED_NOTICE("tests_moved_notice"), + LAST_ARTICLES_REFRESH("last_articles_refresh"), ROUTE("route"), diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/PreferenceRepository.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/PreferenceRepository.kt index d11c1d83c..b2d80180a 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/PreferenceRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/PreferenceRepository.kt @@ -82,6 +82,7 @@ class PreferenceRepository( -> PreferenceKey.IntKey(intPreferencesKey(preferenceKey)) SettingsKey.LAST_RUN_DISMISSED, + SettingsKey.LAST_ARTICLES_REFRESH, -> PreferenceKey.LongKey(longPreferencesKey(preferenceKey)) SettingsKey.LEGACY_PROXY_HOSTNAME, 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 cd1ee3ab0..f3e8eb492 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -74,6 +74,8 @@ import org.ooni.probe.domain.ShouldShowVpnWarning import org.ooni.probe.domain.UploadMissingMeasurements import org.ooni.probe.domain.appreview.MarkAppReviewAsShown import org.ooni.probe.domain.appreview.ShouldShowAppReview +import org.ooni.probe.domain.articles.GetFindings +import org.ooni.probe.domain.articles.GetRSSFeed import org.ooni.probe.domain.articles.RefreshArticles import org.ooni.probe.domain.descriptors.AcceptDescriptorUpdate import org.ooni.probe.domain.descriptors.BootstrapTestDescriptors @@ -496,9 +498,22 @@ class Dependencies( } val refreshArticles by lazy { RefreshArticles( - httpDo = engine::httpDo, - json = json, + sources = listOf( + GetRSSFeed( + engine::httpDo, + "https://ooni.org/blog/index.xml", + ArticleModel.Source.Blog, + ), + GetRSSFeed( + engine::httpDo, + "https://ooni.org/reports/index.xml", + ArticleModel.Source.Report, + ), + GetFindings(engine::httpDo, json), + ), refreshArticlesInDatabase = articleRepository::refresh, + getPreference = preferenceRepository::getValueByKey, + setPreference = preferenceRepository::setValueByKey, ) } val runBackgroundStateManager by lazy { RunBackgroundStateManager() } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt index 5bec3a6e8..fbf5650a5 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt @@ -4,18 +4,22 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import kotlinx.serialization.json.Json -import org.ooni.engine.Engine.MkException -import org.ooni.engine.models.Failure +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import org.ooni.engine.models.Result -import org.ooni.engine.models.TaskOrigin +import org.ooni.engine.models.Success import org.ooni.probe.config.OrganizationConfig import org.ooni.probe.data.models.ArticleModel +import org.ooni.probe.data.models.SettingsKey +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant class RefreshArticles( - val httpDo: suspend (String, String, TaskOrigin) -> Result, - val json: Json, + val sources: List, val refreshArticlesInDatabase: suspend (List) -> Unit, + val getPreference: (SettingsKey) -> Flow, + val setPreference: suspend (SettingsKey, Any) -> Unit, ) { fun interface Source { suspend operator fun invoke(): Result, Exception> @@ -24,11 +28,9 @@ class RefreshArticles( suspend operator fun invoke() { if (!OrganizationConfig.hasOoniNews) return - val sources = listOf( - GetRSSFeed(httpDo, "https://ooni.org/blog/index.xml", ArticleModel.Source.Blog), - GetRSSFeed(httpDo, "https://ooni.org/reports/index.xml", ArticleModel.Source.Report), - GetFindings(httpDo, json), - ) + val lastCheck = (getPreference(SettingsKey.LAST_ARTICLES_REFRESH).first() as? Long) + ?.let { Instant.fromEpochSeconds(it) } + if (lastCheck != null && Clock.System.now() - lastCheck < MIN_INTERVAL) return val responses = sources .map { @@ -41,10 +43,16 @@ class RefreshArticles( } } - if (responses.any { it is Failure }) return + if (responses.all { it is Success }) { + refreshArticlesInDatabase( + responses.mapNotNull { it.get() }.flatten(), + ) + } + + setPreference(SettingsKey.LAST_ARTICLES_REFRESH, Clock.System.now().epochSeconds) + } - refreshArticlesInDatabase( - responses.mapNotNull { it.get() }.flatten(), - ) + companion object { + private val MIN_INTERVAL = 1.days } } diff --git a/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/RefreshArticlesTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/RefreshArticlesTest.kt new file mode 100644 index 000000000..d034d4c55 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/RefreshArticlesTest.kt @@ -0,0 +1,73 @@ +package org.ooni.probe.domain.articles + +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.ooni.engine.models.Failure +import org.ooni.engine.models.Success +import org.ooni.probe.data.models.ArticleModel +import org.ooni.testing.factories.ArticleModelFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Clock + +class RefreshArticlesTest { + @Test + fun doNotRefreshOnFailure() = + runTest { + var dbCalled = false + var setPreferenceValue: Any? = null + val subject = RefreshArticles( + sources = listOf(RefreshArticles.Source { Failure(Exception()) }), + refreshArticlesInDatabase = { dbCalled = true }, + getPreference = { flowOf(null) }, + setPreference = { _, value -> setPreferenceValue = value }, + ) + + subject() + + assertFalse(dbCalled) + assertTrue(Clock.System.now().epochSeconds - (setPreferenceValue as Long) <= 1L) + } + + @Test + fun doNotRefreshTooSoon() = + runTest { + var sourceCalled = false + val subject = RefreshArticles( + sources = listOf( + RefreshArticles.Source { + sourceCalled = true + Failure(Exception()) + }, + ), + refreshArticlesInDatabase = { }, + getPreference = { flowOf(Clock.System.now().epochSeconds) }, + setPreference = { _, _ -> }, + ) + + subject() + + assertFalse(sourceCalled) + } + + @Test + fun success() = + runTest { + var refreshDbValue: List? = null + var setPreferenceValue: Any? = null + val articles = listOf(ArticleModelFactory.build()) + val subject = RefreshArticles( + sources = listOf(RefreshArticles.Source { Success(articles) }), + refreshArticlesInDatabase = { refreshDbValue = it }, + getPreference = { flowOf(null) }, + setPreference = { _, value -> setPreferenceValue = value }, + ) + + subject() + + assertEquals(articles, refreshDbValue) + assertTrue(Clock.System.now().epochSeconds - (setPreferenceValue as Long) <= 1L) + } +} From e96fcebf995abb0f7f837e41b91567622c8e679d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Tue, 28 Oct 2025 11:53:11 +0000 Subject: [PATCH 12/21] Fix manual articles refresh. Use base URLs. --- .../ooni/probe/domain/articles/GetFindings.kt | 10 ++++++--- .../probe/domain/articles/RefreshArticles.kt | 10 +++++++-- .../probe/ui/articles/ArticlesViewModel.kt | 4 ++-- .../domain/articles/RefreshArticlesTest.kt | 21 +++++++++++++++++++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt index 40c773d24..5c30c5e6b 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt @@ -10,6 +10,7 @@ import org.ooni.engine.models.Failure import org.ooni.engine.models.Result import org.ooni.engine.models.Success import org.ooni.engine.models.TaskOrigin +import org.ooni.probe.config.OrganizationConfig import org.ooni.probe.data.models.ArticleModel import org.ooni.probe.shared.toLocalDateTime import kotlin.time.Instant @@ -19,8 +20,11 @@ class GetFindings( val json: Json, ) : RefreshArticles.Source { override suspend operator fun invoke(): Result, Exception> { - return httpDo("GET", "https://api.ooni.org/api/v1/incidents/search", TaskOrigin.OoniRun) - .mapError { Exception("Failed to get findings", it) } + return httpDo( + "GET", + "${OrganizationConfig.ooniApiBaseUrl}/api/v1/incidents/search", + TaskOrigin.OoniRun, + ).mapError { Exception("Failed to get findings", it) } .flatMap { response -> if (response.isNullOrBlank()) return@flatMap Failure(Exception("Empty response")) @@ -37,7 +41,7 @@ class GetFindings( private fun Wrapper.Incident.toArticle() = run { ArticleModel( - url = id?.let { ArticleModel.Url("https://explorer.ooni.org/findings/$it") } + url = id?.let { ArticleModel.Url("${OrganizationConfig.explorerUrl}/findings/$it") } ?: return@run null, title = title ?: return@run null, source = ArticleModel.Source.Finding, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt index fbf5650a5..a2bc64338 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt @@ -25,12 +25,18 @@ class RefreshArticles( suspend operator fun invoke(): Result, Exception> } - suspend operator fun invoke() { + suspend operator fun invoke(skipIntervalCheck: Boolean = false) { if (!OrganizationConfig.hasOoniNews) return val lastCheck = (getPreference(SettingsKey.LAST_ARTICLES_REFRESH).first() as? Long) ?.let { Instant.fromEpochSeconds(it) } - if (lastCheck != null && Clock.System.now() - lastCheck < MIN_INTERVAL) return + if ( + !skipIntervalCheck && + lastCheck != null && + Clock.System.now() - lastCheck < MIN_INTERVAL + ) { + return + } val responses = sources .map { diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesViewModel.kt index 9d707e6ac..341b55289 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesViewModel.kt @@ -16,7 +16,7 @@ class ArticlesViewModel( onBack: () -> Unit, goToArticle: (ArticleModel.Url) -> Unit, getArticles: () -> Flow>, - refreshArticles: suspend () -> Unit, + refreshArticles: suspend (Boolean) -> Unit, canPullToRefresh: Boolean, ) : ViewModel() { private val events = MutableSharedFlow(extraBufferCapacity = 1) @@ -44,7 +44,7 @@ class ArticlesViewModel( .onEach { if (state.value.isRefreshing) return@onEach _state.update { it.copy(isRefreshing = true) } - refreshArticles() + refreshArticles(true) _state.update { it.copy(isRefreshing = false) } }.launchIn(viewModelScope) } diff --git a/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/RefreshArticlesTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/RefreshArticlesTest.kt index d034d4c55..e270b9969 100644 --- a/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/RefreshArticlesTest.kt +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/RefreshArticlesTest.kt @@ -52,6 +52,27 @@ class RefreshArticlesTest { assertFalse(sourceCalled) } + @Test + fun refreshSoonerIfSkip() = + runTest { + var sourceCalled = false + val subject = RefreshArticles( + sources = listOf( + RefreshArticles.Source { + sourceCalled = true + Failure(Exception()) + }, + ), + refreshArticlesInDatabase = { }, + getPreference = { flowOf(Clock.System.now().epochSeconds) }, + setPreference = { _, _ -> }, + ) + + subject(skipIntervalCheck = true) + + assertTrue(sourceCalled) + } + @Test fun success() = runTest { From 78daf258c66098c756934bbe8faed832e824164f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Wed, 29 Oct 2025 15:38:32 +0000 Subject: [PATCH 13/21] Add OONI run link manually --- .../values/strings-common.xml | 5 +- .../commonMain/kotlin/org/ooni/probe/App.kt | 4 +- .../ooni/probe/config/OrganizationConfig.kt | 1 + .../kotlin/org/ooni/probe/di/Dependencies.kt | 11 ++ .../ui/descriptor/add/AddDescriptorMessage.kt | 35 ++++++ .../ui/descriptor/add/AddDescriptorScreen.kt | 9 +- .../descriptor/add/AddDescriptorUrlDialog.kt | 100 ++++++++++++++++++ .../add/AddDescriptorUrlViewModel.kt | 84 +++++++++++++++ .../descriptor/add/AddDescriptorViewModel.kt | 29 ++--- .../probe/ui/descriptors/DescriptorsScreen.kt | 17 ++- .../ui/descriptors/DescriptorsViewModel.kt | 8 ++ .../ooni/probe/ui/navigation/Navigation.kt | 13 +++ .../org/ooni/probe/ui/navigation/Screen.kt | 5 +- .../probe/ui/shared/NotificationMessages.kt | 33 ------ .../ui/upload/UploadMeasurementsDialog.kt | 2 +- .../ooni/probe/config/OrganizationConfig.kt | 1 + .../ooni/probe/config/OrganizationConfig.kt | 1 + .../kotlin/org/ooni/probe/ui/Colors.kt | 4 +- 18 files changed, 295 insertions(+), 67 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorMessage.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorUrlDialog.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorUrlViewModel.kt delete mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/NotificationMessages.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index 0caceb1c4..855e93440 100644 --- a/composeApp/src/commonMain/composeResources/values/strings-common.xml +++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml @@ -384,6 +384,8 @@ + Enter a OONI Run Link URL + The URL is not a valid OONI Run Link Test Settings Install New Link Install updates automatically @@ -393,8 +395,7 @@ Install & Run Unsupported URL Link Loading - Error - Link installation cancelled + Error loading link UPDATES Check for Updates Auto-run is disabled. Please enable it to run tests automatically. diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt index 6c4cf75fa..9546e61cb 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt @@ -134,9 +134,7 @@ fun App( LaunchedEffect(deepLink) { when (deepLink) { is DeepLink.AddDescriptor -> { - navController.navigate( - Screen.AddDescriptor(deepLink.id.toLongOrNull() ?: return@LaunchedEffect), - ) + navController.navigate(Screen.AddDescriptor(deepLink.id)) onDeeplinkHandled() } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt index fb96850cb..d76aab73c 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt @@ -11,6 +11,7 @@ interface OrganizationConfigInterface { val hasWebsitesDescriptor: Boolean val donateUrl: String? val hasOoniNews: Boolean + val canInstallDescriptors: Boolean val ooniApiBaseUrl get() = BuildTypeDefaults.ooniApiBaseUrl val ooniRunDomain get() = BuildTypeDefaults.ooniRunDomain 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 f3e8eb492..81ddcbc74 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -105,6 +105,7 @@ import org.ooni.probe.ui.articles.ArticlesViewModel import org.ooni.probe.ui.choosewebsites.ChooseWebsitesViewModel import org.ooni.probe.ui.dashboard.DashboardViewModel import org.ooni.probe.ui.descriptor.DescriptorViewModel +import org.ooni.probe.ui.descriptor.add.AddDescriptorUrlViewModel import org.ooni.probe.ui.descriptor.add.AddDescriptorViewModel import org.ooni.probe.ui.descriptor.review.ReviewUpdatesViewModel import org.ooni.probe.ui.descriptor.websites.DescriptorWebsitesViewModel @@ -595,6 +596,14 @@ class Dependencies( startBackgroundRun = startSingleRunInner, ) + fun addDescriptorUrlViewModel( + onClose: () -> Unit, + goToAddDescriptor: (InstalledTestDescriptorModel.Id) -> Unit, + ) = AddDescriptorUrlViewModel( + onClose = onClose, + goToAddDescriptor = goToAddDescriptor, + ) + fun articleViewModel( url: ArticleModel.Url, onBack: () -> Unit, @@ -664,9 +673,11 @@ class Dependencies( fun descriptorsViewModel( goToDescriptor: (String) -> Unit, goToReviewDescriptorUpdates: (List?) -> Unit, + goToAddDescriptorUrl: () -> Unit, ) = DescriptorsViewModel( goToDescriptor = goToDescriptor, goToReviewDescriptorUpdates = goToReviewDescriptorUpdates, + goToAddDescriptorUrl = goToAddDescriptorUrl, getTestDescriptors = getTestDescriptors::latest, startDescriptorsUpdates = startDescriptorsUpdate, dismissDescriptorsUpdateNotice = dismissDescriptorReviewNotice::invoke, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorMessage.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorMessage.kt new file mode 100644 index 000000000..ecb5de12b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorMessage.kt @@ -0,0 +1,35 @@ +package org.ooni.probe.ui.descriptor.add + +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import ooniprobe.composeapp.generated.resources.AddDescriptor_Toasts_Installed +import ooniprobe.composeapp.generated.resources.LoadingScreen_Runv2_Failure +import ooniprobe.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.getString +import org.ooni.probe.LocalSnackbarHostState + +@Composable +fun AddDescriptorMessage( + message: AddDescriptorViewModel.Message?, + onMessageDisplayed: (AddDescriptorViewModel.Message) -> Unit = { }, +) { + val snackbarHostState = LocalSnackbarHostState.current ?: return + LaunchedEffect(message) { + val message = message ?: return@LaunchedEffect + val result = snackbarHostState.showSnackbar( + getString( + when (message) { + AddDescriptorViewModel.Message.AddDescriptorSuccess -> + Res.string.AddDescriptor_Toasts_Installed + + AddDescriptorViewModel.Message.FailedToFetch -> + Res.string.LoadingScreen_Runv2_Failure + }, + ), + ) + if (result == SnackbarResult.Dismissed) { + onMessageDisplayed(message) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorScreen.kt index 59c2864e5..cd54fdc13 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorScreen.kt @@ -47,7 +47,6 @@ import org.ooni.probe.ui.dashboard.TestDescriptorLabel import org.ooni.probe.ui.descriptor.isSingleWebConnectivityTest import org.ooni.probe.ui.run.TestItem import org.ooni.probe.ui.shared.NavigationCloseButton -import org.ooni.probe.ui.shared.NotificationMessages import org.ooni.probe.ui.shared.TopBar import org.ooni.probe.ui.shared.VerticalScrollbar @@ -217,11 +216,9 @@ fun AddDescriptorScreen( } } ?: LoadingDescriptor() - NotificationMessages( - message = state.messages, - onMessageDisplayed = { - onEvent(AddDescriptorViewModel.Event.MessageDisplayed(it)) - }, + AddDescriptorMessage( + message = state.messages.firstOrNull(), + onMessageDisplayed = { onEvent(AddDescriptorViewModel.Event.MessageDisplayed(it)) }, ) } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorUrlDialog.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorUrlDialog.kt new file mode 100644 index 000000000..a8f5a26ca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorUrlDialog.kt @@ -0,0 +1,100 @@ +package org.ooni.probe.ui.descriptor.add + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import ooniprobe.composeapp.generated.resources.AddDescriptor_EnterURL +import ooniprobe.composeapp.generated.resources.AddDescriptor_Title +import ooniprobe.composeapp.generated.resources.AddDescriptor_URLInvalid +import ooniprobe.composeapp.generated.resources.Common_Next +import ooniprobe.composeapp.generated.resources.Modal_Cancel +import ooniprobe.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun AddDescriptorUrlDialog( + state: AddDescriptorUrlViewModel.State, + onEvent: (AddDescriptorUrlViewModel.Event) -> Unit, +) { + Surface(shape = MaterialTheme.shapes.medium) { + Column( + Modifier.padding(all = 16.dp).fillMaxWidth(), + ) { + Text( + stringResource(Res.string.AddDescriptor_Title), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), + ) + Text( + stringResource(Res.string.AddDescriptor_EnterURL), + modifier = Modifier.padding(bottom = 16.dp), + ) + + OutlinedTextField( + value = state.input, + onValueChange = { onEvent(AddDescriptorUrlViewModel.Event.InputChanged(it)) }, + placeholder = { Text(AddDescriptorUrlViewModel.RUN_LINK_PREFIX + "…") }, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Uri, + ), + isError = state.isInvalid, + modifier = Modifier.fillMaxWidth(), + ) + if (state.isInvalid) { + Text( + text = stringResource(Res.string.AddDescriptor_URLInvalid), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 4.dp), + ) + } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + ) { + TextButton(onClick = { onEvent(AddDescriptorUrlViewModel.Event.CloseClicked) }) { + Text(stringResource(Res.string.Modal_Cancel)) + } + Button( + onClick = { onEvent(AddDescriptorUrlViewModel.Event.NextClicked) }, + enabled = !state.isInvalid, + ) { + Text(stringResource(Res.string.Common_Next)) + } + } + } + } +} + +@Composable +@Preview +fun AddDescriptorUrlDialogPreview() { + AddDescriptorUrlDialog( + state = AddDescriptorUrlViewModel.State(), + onEvent = {}, + ) +} + +@Composable +@Preview +fun AddDescriptorUrlDialogInvalidPreview() { + AddDescriptorUrlDialog( + state = AddDescriptorUrlViewModel.State(input = "invalid", isInvalid = true), + onEvent = {}, + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorUrlViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorUrlViewModel.kt new file mode 100644 index 000000000..8a7a7ad6e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorUrlViewModel.kt @@ -0,0 +1,84 @@ +package org.ooni.probe.ui.descriptor.add + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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 +import org.ooni.probe.config.OrganizationConfig +import org.ooni.probe.data.models.InstalledTestDescriptorModel + +class AddDescriptorUrlViewModel( + onClose: () -> Unit, + goToAddDescriptor: (InstalledTestDescriptorModel.Id) -> Unit, +) : ViewModel() { + private val events = MutableSharedFlow(extraBufferCapacity = 1) + + private val _state = MutableStateFlow(State()) + val state = _state.asStateFlow() + + init { + events + .filterIsInstance() + .onEach { onClose() } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { event -> + _state.update { + it.copy( + input = event.value, + isInvalid = false, + ) + } + }.launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { + val input = _state.value.input + if (!input.isValidInput()) { + _state.update { it.copy(isInvalid = true) } + return@onEach + } + + goToAddDescriptor( + InstalledTestDescriptorModel.Id( + input.removePrefix(RUN_LINK_PREFIX), + ), + ) + }.launchIn(viewModelScope) + } + + fun onEvent(event: Event) { + events.tryEmit(event) + } + + private fun String.isValidInput() = + toLongOrNull() != null || + (startsWith(RUN_LINK_PREFIX) && length > RUN_LINK_PREFIX.length) + + data class State( + val input: String = "", + val isInvalid: Boolean = false, + ) + + sealed interface Event { + data object CloseClicked : Event + + data class InputChanged( + val value: String, + ) : Event + + data object NextClicked : Event + } + + companion object { + val RUN_LINK_PREFIX = "${OrganizationConfig.ooniRunDashboardUrl}/v2/" + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorViewModel.kt index 36b87ae0d..f7ca3285f 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorViewModel.kt @@ -52,9 +52,9 @@ class AddDescriptorViewModel( }.orEmpty(), ) }.onFailure { error -> - Logger.e("Failed to fetch descriptor", error) + Logger.i("Failed to fetch descriptor", error) _state.update { - it.copy(messages = it.messages + SnackBarMessage.AddDescriptorFailed) + it.copy(messages = it.messages + Message.FailedToFetch) } onBack() } @@ -93,19 +93,15 @@ class AddDescriptorViewModel( events .filterIsInstance() - .onEach { event -> - _state.update { - it.copy(messages = it.messages + SnackBarMessage.AddDescriptorCancel) - } - onBack() - }.launchIn(viewModelScope) + .onEach { onBack() } + .launchIn(viewModelScope) events .filterIsInstance() .onEach { event -> installDescriptorAndSavePreferences() _state.update { - it.copy(messages = it.messages + SnackBarMessage.AddDescriptorSuccess) + it.copy(messages = it.messages + Message.AddDescriptorSuccess) } onBack() }.launchIn(viewModelScope) @@ -119,7 +115,7 @@ class AddDescriptorViewModel( RunSpecification.buildForDescriptor(installedDescriptor.toDescriptor()), ) _state.update { - it.copy(messages = it.messages + SnackBarMessage.AddDescriptorSuccess) + it.copy(messages = it.messages + Message.AddDescriptorSuccess) } onBack() }.launchIn(viewModelScope) @@ -160,7 +156,7 @@ class AddDescriptorViewModel( data class State( val descriptor: InstalledTestDescriptorModel? = null, val selectableItems: List> = emptyList(), - val messages: List = emptyList(), + val messages: List = emptyList(), val autoUpdate: Boolean = true, ) { fun allTestsSelected(): ToggleableState { @@ -194,15 +190,12 @@ class AddDescriptorViewModel( ) : Event data class MessageDisplayed( - val message: SnackBarMessage, + val message: Message, ) : Event } - sealed interface SnackBarMessage { - data object AddDescriptorFailed : SnackBarMessage - - data object AddDescriptorCancel : SnackBarMessage - - data object AddDescriptorSuccess : SnackBarMessage + enum class Message { + FailedToFetch, + AddDescriptorSuccess, } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt index da3f713a0..0ca227ee8 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt @@ -15,7 +15,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -27,6 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp +import ooniprobe.composeapp.generated.resources.AddDescriptor_Title import ooniprobe.composeapp.generated.resources.Common_Collapse import ooniprobe.composeapp.generated.resources.Common_Expand import ooniprobe.composeapp.generated.resources.DescriptorUpdate_CheckUpdates @@ -37,6 +41,7 @@ import ooniprobe.composeapp.generated.resources.ic_keyboard_arrow_up import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview +import org.ooni.probe.config.OrganizationConfig import org.ooni.probe.data.models.DescriptorType import org.ooni.probe.data.models.DescriptorUpdateOperationState import org.ooni.probe.ui.shared.TopBar @@ -63,13 +68,23 @@ fun DescriptorsScreen( Column(Modifier.fillMaxSize()) { TopBar( title = { Text(stringResource(Res.string.Tests_Title)) }, + actions = { + if (OrganizationConfig.canInstallDescriptors) { + IconButton(onClick = { onEvent(DescriptorsViewModel.Event.AddClicked) }) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(Res.string.AddDescriptor_Title), + ) + } + } + }, ) Box { val lazyListState = rememberLazyListState() LazyColumn( modifier = Modifier.testTag("Descriptors-List"), - contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp), + contentPadding = PaddingValues(vertical = 16.dp), state = lazyListState, ) { val allSectionsHaveValues = state.sections.all { it.descriptors.any() } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsViewModel.kt index e0f468975..98da1aac1 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsViewModel.kt @@ -22,6 +22,7 @@ import org.ooni.probe.data.models.SettingsKey class DescriptorsViewModel( goToDescriptor: (String) -> Unit, goToReviewDescriptorUpdates: (List?) -> Unit, + goToAddDescriptorUrl: () -> Unit, getTestDescriptors: () -> Flow>, observeDescriptorUpdateState: () -> Flow, startDescriptorsUpdates: suspend (List?) -> Unit, @@ -92,6 +93,11 @@ class DescriptorsViewModel( .filterIsInstance() .onEach { dismissDescriptorsUpdateNotice() } .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { goToAddDescriptorUrl() } + .launchIn(viewModelScope) } fun onEvent(event: Event) { @@ -165,6 +171,8 @@ class DescriptorsViewModel( data object ReviewUpdatesClicked : Event data object CancelUpdatesClicked : Event + + data object AddClicked : Event } data class DescriptorSection( 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 4b06762a2..44ca9cb43 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 @@ -29,6 +29,7 @@ import org.ooni.probe.ui.choosewebsites.ChooseWebsitesScreen import org.ooni.probe.ui.dashboard.DashboardScreen import org.ooni.probe.ui.descriptor.DescriptorScreen import org.ooni.probe.ui.descriptor.add.AddDescriptorScreen +import org.ooni.probe.ui.descriptor.add.AddDescriptorUrlDialog import org.ooni.probe.ui.descriptor.review.ReviewUpdatesScreen import org.ooni.probe.ui.descriptor.websites.DescriptorWebsitesViewModel import org.ooni.probe.ui.descriptors.DescriptorsScreen @@ -107,6 +108,7 @@ fun Navigation( goToReviewDescriptorUpdates = { list -> navController.safeNavigate(Screen.ReviewUpdates(list?.map { it.value })) }, + goToAddDescriptorUrl = { navController.safeNavigate(Screen.AddDescriptorUrl) }, ) } val state by viewModel.state.collectAsState() @@ -288,6 +290,17 @@ fun Navigation( AddDescriptorScreen(state, viewModel::onEvent) } + dialog { entry -> + val viewModel = viewModel { + dependencies.addDescriptorUrlViewModel( + onClose = { navController.goBack() }, + goToAddDescriptor = { navController.safeNavigate(Screen.AddDescriptor(it.value)) }, + ) + } + val state by viewModel.state.collectAsState() + AddDescriptorUrlDialog(state, viewModel::onEvent) + } + composable { val viewModel = viewModel { dependencies.runningViewModel( 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 2f343d132..22a1636e6 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 @@ -26,9 +26,12 @@ sealed interface Screen { @Serializable data class AddDescriptor( - val runId: Long, + val runId: String, ) : Screen + @Serializable + data object AddDescriptorUrl : Screen + @Serializable data class Measurement( val measurementId: Long, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/NotificationMessages.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/NotificationMessages.kt deleted file mode 100644 index 476a634ae..000000000 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/NotificationMessages.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.ooni.probe.ui.shared - -import androidx.compose.material3.SnackbarResult -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import ooniprobe.composeapp.generated.resources.AddDescriptor_Toasts_Installed -import ooniprobe.composeapp.generated.resources.LoadingScreen_Runv2_Canceled -import ooniprobe.composeapp.generated.resources.LoadingScreen_Runv2_Failure -import ooniprobe.composeapp.generated.resources.Res -import org.jetbrains.compose.resources.getString -import org.ooni.probe.LocalSnackbarHostState -import org.ooni.probe.ui.descriptor.add.AddDescriptorViewModel - -@Composable -fun NotificationMessages( - message: List, - onMessageDisplayed: (AddDescriptorViewModel.SnackBarMessage) -> Unit = { }, -) { - val snackbarHostState = LocalSnackbarHostState.current ?: return - LaunchedEffect(message) { - val errorMessage = when (message.firstOrNull()) { - AddDescriptorViewModel.SnackBarMessage.AddDescriptorSuccess -> getString(Res.string.AddDescriptor_Toasts_Installed) - AddDescriptorViewModel.SnackBarMessage.AddDescriptorFailed -> getString(Res.string.LoadingScreen_Runv2_Failure) - AddDescriptorViewModel.SnackBarMessage.AddDescriptorCancel -> getString(Res.string.LoadingScreen_Runv2_Canceled) - else -> "" - } - val error = message.firstOrNull() ?: return@LaunchedEffect - val result = snackbarHostState.showSnackbar(errorMessage) - if (result == SnackbarResult.Dismissed) { - onMessageDisplayed(error) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/upload/UploadMeasurementsDialog.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/upload/UploadMeasurementsDialog.kt index fca91f520..cdc264b29 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/upload/UploadMeasurementsDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/upload/UploadMeasurementsDialog.kt @@ -33,7 +33,7 @@ fun UploadMeasurementsDialog( state: UploadMissingMeasurements.State, onEvent: (UploadMeasurementsViewModel.Event) -> Unit, ) { - Surface { + Surface(shape = MaterialTheme.shapes.medium) { Column( modifier = Modifier .padding(16.dp) diff --git a/composeApp/src/dwMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt b/composeApp/src/dwMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt index 1ccd247f5..fb719dac6 100644 --- a/composeApp/src/dwMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt +++ b/composeApp/src/dwMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt @@ -16,4 +16,5 @@ object OrganizationConfig : OrganizationConfigInterface { override val hasWebsitesDescriptor = false override val donateUrl = null override val hasOoniNews = false + override val canInstallDescriptors = false } diff --git a/composeApp/src/ooniMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt b/composeApp/src/ooniMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt index fe67b3b19..b4bd83706 100644 --- a/composeApp/src/ooniMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt +++ b/composeApp/src/ooniMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt @@ -18,4 +18,5 @@ object OrganizationConfig : OrganizationConfigInterface { override val hasWebsitesDescriptor = true override val donateUrl = "https://ooni.org/donate" override val hasOoniNews = true + override val canInstallDescriptors = true } diff --git a/composeApp/src/ooniMain/kotlin/org/ooni/probe/ui/Colors.kt b/composeApp/src/ooniMain/kotlin/org/ooni/probe/ui/Colors.kt index 54dd593e9..b7da1440b 100644 --- a/composeApp/src/ooniMain/kotlin/org/ooni/probe/ui/Colors.kt +++ b/composeApp/src/ooniMain/kotlin/org/ooni/probe/ui/Colors.kt @@ -23,7 +23,7 @@ val onBackgroundLight = Color(0xFF000000) val surfaceLight = Color(0xFFF7F9FF) val onSurfaceLight = Color(0xFF000000) val surfaceVariantLight = Color(0xFFF0F0F0) -val onSurfaceVariantLight = Color(0xFF000000) +val onSurfaceVariantLight = Color(0xFF777777) val outlineLight = Color(0xFF74777F) val outlineVariantLight = Color(0xFFC4C6D0) val scrimLight = Color(0xFF000000) @@ -59,7 +59,7 @@ val onBackgroundDark = Color(0xFFE0E2E8) val surfaceDark = Color(0xFF101418) val onSurfaceDark = Color(0xFFE0E2E8) val surfaceVariantDark = Color(0xFF333333) -val onSurfaceVariantDark = Color(0xFFE0E2E8) +val onSurfaceVariantDark = Color(0xFFCCCCCC) val outlineDark = Color(0xFF8E9099) val outlineVariantDark = Color(0xFF44474E) val scrimDark = Color(0xFF000000) From 38ee4f241e8cce350de3f94108261449e6a1a3ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Wed, 29 Oct 2025 18:04:15 +0000 Subject: [PATCH 14/21] Filter tests --- .../values/strings-common.xml | 2 + .../probe/ui/descriptors/DescriptorsScreen.kt | 143 ++++++++++++++---- .../ui/descriptors/DescriptorsViewModel.kt | 26 ++++ 3 files changed, 142 insertions(+), 29 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index 855e93440..17b02e14b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings-common.xml +++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml @@ -82,6 +82,7 @@ Tests + Search tests Websites Instant Messaging Performance @@ -464,6 +465,7 @@ Next Previous Dismiss + Search Today Yesterday diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt index 0ca227ee8..d838a5d77 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt @@ -7,34 +7,49 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.pullToRefresh import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import ooniprobe.composeapp.generated.resources.AddDescriptor_Title +import ooniprobe.composeapp.generated.resources.Common_Clear import ooniprobe.composeapp.generated.resources.Common_Collapse import ooniprobe.composeapp.generated.resources.Common_Expand +import ooniprobe.composeapp.generated.resources.Common_Search import ooniprobe.composeapp.generated.resources.DescriptorUpdate_CheckUpdates import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.Tests_Search import ooniprobe.composeapp.generated.resources.Tests_Title import ooniprobe.composeapp.generated.resources.ic_keyboard_arrow_down import ooniprobe.composeapp.generated.resources.ic_keyboard_arrow_up @@ -42,8 +57,11 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview import org.ooni.probe.config.OrganizationConfig +import org.ooni.probe.data.models.Descriptor import org.ooni.probe.data.models.DescriptorType import org.ooni.probe.data.models.DescriptorUpdateOperationState +import org.ooni.probe.ui.shared.ColorDefaults +import org.ooni.probe.ui.shared.NavigationBackButton import org.ooni.probe.ui.shared.TopBar import org.ooni.probe.ui.shared.UpdateProgressStatus import org.ooni.probe.ui.shared.VerticalScrollbar @@ -66,19 +84,74 @@ fun DescriptorsScreen( .fillMaxSize(), ) { Column(Modifier.fillMaxSize()) { - TopBar( - title = { Text(stringResource(Res.string.Tests_Title)) }, - actions = { - if (OrganizationConfig.canInstallDescriptors) { - IconButton(onClick = { onEvent(DescriptorsViewModel.Event.AddClicked) }) { - Icon( - Icons.Default.Add, - contentDescription = stringResource(Res.string.AddDescriptor_Title), - ) - } + if (state.isFiltering) { + Surface( + color = ColorDefaults.topAppBar().containerColor, + contentColor = ColorDefaults.topAppBar().titleContentColor, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(WindowInsets.statusBars.asPaddingValues()) + .defaultMinSize(minHeight = TopAppBarDefaults.TopAppBarExpandedHeight), + ) { + NavigationBackButton(onClick = { onEvent(DescriptorsViewModel.Event.CloseFilterClicked) }) + + OutlinedTextField( + value = state.filterText.orEmpty(), + onValueChange = { + onEvent(DescriptorsViewModel.Event.FilterTextChanged(it)) + }, + placeholder = { Text(stringResource(Res.string.Tests_Search)) }, + trailingIcon = { + IconButton( + onClick = { + onEvent(DescriptorsViewModel.Event.FilterTextChanged("")) + }, + enabled = !state.filterText.isNullOrEmpty(), + ) { + Icon( + Icons.Default.Clear, + contentDescription = stringResource(Res.string.Common_Clear), + ) + } + }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + focusedTextColor = LocalContentColor.current, + unfocusedTextColor = LocalContentColor.current, + focusedPlaceholderColor = LocalContentColor.current.copy(alpha = 0.7f), + unfocusedPlaceholderColor = LocalContentColor.current.copy(alpha = 0.7f), + focusedTrailingIconColor = LocalContentColor.current, + unfocusedTrailingIconColor = LocalContentColor.current, + cursorColor = LocalContentColor.current, + ), + modifier = Modifier.fillMaxWidth(), + ) } - }, - ) + } + } else { + TopBar( + title = { Text(stringResource(Res.string.Tests_Title)) }, + actions = { + if (OrganizationConfig.canInstallDescriptors) { + IconButton(onClick = { onEvent(DescriptorsViewModel.Event.FilterClicked) }) { + Icon( + Icons.Default.Search, + contentDescription = stringResource(Res.string.Common_Search), + ) + } + IconButton(onClick = { onEvent(DescriptorsViewModel.Event.AddClicked) }) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(Res.string.AddDescriptor_Title), + ) + } + } + }, + ) + } Box { val lazyListState = rememberLazyListState() @@ -89,7 +162,7 @@ fun DescriptorsScreen( ) { val allSectionsHaveValues = state.sections.all { it.descriptors.any() } state.sections.forEach { (type, descriptors, isCollapsed) -> - if (allSectionsHaveValues && descriptors.isNotEmpty()) { + if (!state.isFiltering && allSectionsHaveValues && descriptors.isNotEmpty()) { item(type) { TestDescriptorSectionTitle( type = type, @@ -99,23 +172,25 @@ fun DescriptorsScreen( ) } } - if (isCollapsed) return@forEach + if (isCollapsed && !state.isFiltering) return@forEach items(descriptors, key = { it.key }) { descriptor -> - TestDescriptorItem( - descriptor = descriptor, - onClick = { - onEvent( - DescriptorsViewModel.Event.DescriptorClicked(descriptor), - ) - }, - onUpdateClick = { - onEvent( - DescriptorsViewModel.Event.UpdateDescriptorClicked( - descriptor, - ), - ) - }, - ) + if (descriptor.matches(state.filterText)) { + TestDescriptorItem( + descriptor = descriptor, + onClick = { + onEvent( + DescriptorsViewModel.Event.DescriptorClicked(descriptor), + ) + }, + onUpdateClick = { + onEvent( + DescriptorsViewModel.Event.UpdateDescriptorClicked( + descriptor, + ), + ) + }, + ) + } } } } @@ -141,6 +216,10 @@ fun DescriptorsScreen( state = pullRefreshState, ) } + + BackHandler(enabled = state.isFiltering) { + onEvent(DescriptorsViewModel.Event.CloseFilterClicked) + } } @Composable @@ -211,6 +290,12 @@ private fun CheckUpdatesButton( } } +@Composable +private fun Descriptor.matches(filter: String?) = + filter == null || + title().contains(filter, ignoreCase = true) || + shortDescription()?.contains(filter, ignoreCase = true) == true + @Preview @Composable fun DashboardScreenPreview() { diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsViewModel.kt index 98da1aac1..46edaeb48 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsViewModel.kt @@ -98,6 +98,21 @@ class DescriptorsViewModel( .filterIsInstance() .onEach { goToAddDescriptorUrl() } .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { _state.update { it.copy(filterText = "") } } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { event -> _state.update { it.copy(filterText = event.text) } } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { _state.update { it.copy(filterText = null) } } + .launchIn(viewModelScope) } fun onEvent(event: Event) { @@ -142,6 +157,7 @@ class DescriptorsViewModel( val availableUpdates: List = emptyList(), val descriptorsUpdateOperationState: DescriptorUpdateOperationState = DescriptorUpdateOperationState.Idle, val canPullToRefresh: Boolean = true, + val filterText: String? = null, ) { val isRefreshing: Boolean get() = descriptorsUpdateOperationState == DescriptorUpdateOperationState.FetchingUpdates @@ -151,6 +167,8 @@ class DescriptorsViewModel( .firstOrNull { it.type == DescriptorType.Installed } ?.descriptors ?.any() == true + + val isFiltering get() = filterText != null } sealed interface Event { @@ -173,6 +191,14 @@ class DescriptorsViewModel( data object CancelUpdatesClicked : Event data object AddClicked : Event + + data object FilterClicked : Event + + data class FilterTextChanged( + val text: String, + ) : Event + + data object CloseFilterClicked : Event } data class DescriptorSection( From 36fc5b0dd0ce8cb7f82c24823ae2f2cd923c7d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Tue, 4 Nov 2025 15:21:24 +0000 Subject: [PATCH 15/21] Improve dashboard cards in dark mode --- .../kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt index aa48e808a..b44d0f0c3 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -28,9 +29,9 @@ fun DashboardCard( endActions: @Composable () -> Unit = {}, icon: Painter? = null, ) { - ElevatedCard( - colors = CardDefaults.elevatedCardColors( - disabledContainerColor = MaterialTheme.colorScheme.surface, + OutlinedCard( + colors = CardDefaults.outlinedCardColors( + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLowest, disabledContentColor = MaterialTheme.colorScheme.onSurface, ), modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp), From 7032827e63addefef5d8cbd1dd64c8facc1ee4e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Tue, 4 Nov 2025 16:56:02 +0000 Subject: [PATCH 16/21] Open articles on external browser --- .../composeResources/drawable/ic_open_external.xml | 11 +++++++++++ .../org/ooni/probe/ui/articles/ArticleScreen.kt | 7 +++++++ .../org/ooni/probe/ui/articles/ArticleViewModel.kt | 7 +++++++ .../org/ooni/probe/ui/dashboard/DashboardCard.kt | 1 - gradle/libs.versions.toml | 2 +- 5 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonMain/composeResources/drawable/ic_open_external.xml diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_open_external.xml b/composeApp/src/commonMain/composeResources/drawable/ic_open_external.xml new file mode 100644 index 000000000..363729716 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_open_external.xml @@ -0,0 +1,11 @@ + + + diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleScreen.kt index 672aa41ba..58ab97e4e 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleScreen.kt @@ -33,6 +33,7 @@ import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Title import ooniprobe.composeapp.generated.resources.Measurement_LoadingFailed import ooniprobe.composeapp.generated.resources.Res import ooniprobe.composeapp.generated.resources.ic_cloud_off +import ooniprobe.composeapp.generated.resources.ic_open_external import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.ooni.probe.ui.shared.NavigationBackButton @@ -58,6 +59,12 @@ fun ArticleScreen( NavigationBackButton({ onEvent(ArticleViewModel.Event.BackClicked) }) }, actions = { + IconButton(onClick = { onEvent(ArticleViewModel.Event.OpenExternal) }) { + Icon( + painterResource(Res.drawable.ic_open_external), + contentDescription = null, + ) + } IconButton(onClick = { onEvent(ArticleViewModel.Event.ShareUrl) }) { Icon( Icons.Default.Share, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleViewModel.kt index 69db67886..fdbabc014 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleViewModel.kt @@ -38,6 +38,11 @@ class ArticleViewModel( .onEach { onBack() } .launchIn(viewModelScope) + events + .filterIsInstance() + .onEach { launchAction(PlatformAction.OpenUrl(url.value)) } + .launchIn(viewModelScope) + events .filterIsInstance() .onEach { launchAction(PlatformAction.Share(url.value)) } @@ -64,6 +69,8 @@ class ArticleViewModel( sealed interface Event { data object BackClicked : Event + data object OpenExternal : Event + data object ShareUrl : Event data class OutsideLinkClicked( diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt index b44d0f0c3..86011c3ae 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bc5d5c207..78ac03b17 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,7 @@ javafx = { id = "org.openjfx.javafxplugin", version = "0.1.0" } # Kotlin kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.9.0" } -kotlin-serialization-xml = { module = "io.github.pdvrieze.xmlutil:serialization", version = "0.91.2" } +kotlin-serialization-xml = { module = "io.github.pdvrieze.xmlutil:serialization", version = "0.91.3" } kotlin-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.7.1" } # Java From 10a3c4b2983156353d605a2c6be1045b60a6e7ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Mon, 3 Nov 2025 18:04:28 +0000 Subject: [PATCH 17/21] Show article images --- composeApp/build.gradle.kts | 1 + .../ooni/probe/data/models/ArticleModel.kt | 3 +- .../data/repositories/ArticleRepository.kt | 6 +- .../ooni/probe/domain/articles/GetFindings.kt | 1 + .../ooni/probe/domain/articles/GetRSSFeed.kt | 16 +- .../org/ooni/probe/ui/articles/ArticleCard.kt | 168 ++++++++++++++++++ .../org/ooni/probe/ui/articles/ArticleCell.kt | 90 ---------- .../ooni/probe/ui/articles/ArticlesScreen.kt | 2 +- .../probe/ui/dashboard/DashboardScreen.kt | 4 +- .../commonMain/sqldelight/migrations/15.sqm | 1 + .../sqldelight/org/ooni/probe/data/Article.sq | 6 +- .../probe/domain/articles/GetRssFeedTest.kt | 42 ++++- .../testing/factories/ArticleModelFactory.kt | 4 +- gradle/libs.versions.toml | 18 +- 14 files changed, 255 insertions(+), 107 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleCard.kt delete mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleCell.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 90d5c7bc1..ef349a867 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -100,6 +100,7 @@ kotlin { iosMain.dependencies { implementation(libs.sqldelight.native) implementation(libs.bundles.mobile) + implementation(libs.bundles.ios) } val desktopMain by getting { dependencies { diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/ArticleModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/ArticleModel.kt index 318e5dbcf..d68f9d9a2 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/ArticleModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/ArticleModel.kt @@ -9,8 +9,9 @@ import org.ooni.probe.shared.today data class ArticleModel( val url: Url, val title: String, - val description: String?, val source: Source, + val description: String?, + val imageUrl: String?, val time: LocalDateTime, ) { data class Url( diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/ArticleRepository.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/ArticleRepository.kt index 3e7043380..e1824863f 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/ArticleRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/ArticleRepository.kt @@ -23,8 +23,9 @@ class ArticleRepository( database.articleQueries.insertOrReplace( url = model.url.value, title = model.title, - description = model.description, source = model.source.value, + description = model.description, + image_url = model.imageUrl, time = model.time.toEpoch(), ) } @@ -45,8 +46,9 @@ class ArticleRepository( ArticleModel( url = ArticleModel.Url(url), title = title, - description = description, source = ArticleModel.Source.fromValue(source) ?: return@run null, + description = description, + imageUrl = image_url, time = time.toLocalDateTime(), ) } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt index 5c30c5e6b..34ee184ad 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt @@ -46,6 +46,7 @@ class GetFindings( title = title ?: return@run null, source = ArticleModel.Source.Finding, description = shortDescription, + imageUrl = null, time = createTime?.toLocalDateTime() ?: return@run null, ) } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetRSSFeed.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetRSSFeed.kt index e68857b87..9f5a0bbc1 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetRSSFeed.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetRSSFeed.kt @@ -9,8 +9,6 @@ import kotlinx.datetime.format.byUnicodePattern import kotlinx.datetime.parse import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString -import nl.adaptivity.xmlutil.ExperimentalXmlUtilApi -import nl.adaptivity.xmlutil.serialization.UnknownChildHandler import nl.adaptivity.xmlutil.serialization.XML import nl.adaptivity.xmlutil.serialization.XmlElement import nl.adaptivity.xmlutil.serialization.XmlSerialName @@ -56,6 +54,7 @@ class GetRSSFeed( title = title ?: return@run null, source = source, description = description, + imageUrl = content?.url, time = pubDate?.toLocalDateTime() ?: return@run null, ) } @@ -80,8 +79,7 @@ class GetRSSFeed( private val Xml by lazy { XML { defaultPolicy { - @OptIn(ExperimentalXmlUtilApi::class) - unknownChildHandler = UnknownChildHandler { _, _, _, _, _ -> emptyList() } + ignoreUnknownChildren() } } } @@ -115,6 +113,16 @@ class GetRSSFeed( @XmlSerialName("pubDate") @XmlElement val pubDate: String?, + @XmlSerialName("content", namespace = "http://search.yahoo.com/mrss/") + @XmlElement + val content: MediaContent?, + ) + + @Serializable + data class MediaContent( + @XmlSerialName("url") + @XmlElement(false) + val url: String?, ) } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleCard.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleCard.kt new file mode 100644 index 000000000..77bb949f1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleCard.kt @@ -0,0 +1,168 @@ +package org.ooni.probe.ui.articles + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImagePainter +import coil3.compose.rememberAsyncImagePainter +import kotlinx.datetime.LocalDate +import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Blog +import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Finding +import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Recent +import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Report +import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.ic_cloud_off +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.ArticleModel +import org.ooni.probe.shared.toDateTime +import org.ooni.probe.ui.shared.articleFormat +import org.ooni.probe.ui.theme.AppTheme +import org.ooni.probe.ui.theme.LocalCustomColors + +@Composable +fun ArticleCard( + article: ArticleModel, + onClick: () -> Unit, +) { + OutlinedCard( + onClick = onClick, + colors = CardDefaults + .outlinedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest), + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp), + ) { + Row( + Modifier + .height(IntrinsicSize.Min) + .defaultMinSize(minHeight = 88.dp), + ) { + Column(Modifier.weight(2f)) { + Text( + article.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 8.dp, bottom = 4.dp), + ) + Text( + buildAnnotatedString { + withStyle( + SpanStyle( + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ), + ) { + append( + stringResource( + when (article.source) { + ArticleModel.Source.Blog -> Res.string.Dashboard_Articles_Blog + ArticleModel.Source.Finding -> Res.string.Dashboard_Articles_Finding + ArticleModel.Source.Report -> Res.string.Dashboard_Articles_Report + }, + ), + ) + } + append(" • ") + append(article.time.articleFormat()) + if (article.isRecent) { + append(" • ") + withStyle(SpanStyle(color = LocalCustomColors.current.success)) { + append(stringResource(Res.string.Dashboard_Articles_Recent)) + } + } + }, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 12.dp).padding(bottom = 8.dp), + ) + } + article.imageUrl?.let { imageUrl -> + val painter = rememberAsyncImagePainter(imageUrl) + val state by painter.state.collectAsStateWithLifecycle() + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxHeight() + .height(IntrinsicSize.Min) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceVariant) + .border( + Dp.Hairline, + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f), + MaterialTheme.shapes.medium, + ).weight(1f), + ) { + Image( + painter = painter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + ) + if (state is AsyncImagePainter.State.Loading) { + CircularProgressIndicator( + Modifier.size(48.dp), + ) + } + if (state is AsyncImagePainter.State.Error) { + Image( + painterResource(Res.drawable.ic_cloud_off), + contentDescription = null, + modifier = Modifier.alpha(0.5f), + ) + } + } + } + } + } +} + +@Composable +@Preview +fun ArticleCellPreview() { + AppTheme { + ArticleCard( + article = ArticleModel( + url = ArticleModel.Url("http://ooni.org"), + title = "Join us at the OMG Village at the Global Gathering 2025!", + description = "Hello there.", + imageUrl = "https://ooni.org/images/logos/OONI-VerticalColor@2x.png", + source = ArticleModel.Source.Blog, + time = LocalDate(2025, 4, 1).toDateTime(), + ), + onClick = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleCell.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleCell.kt deleted file mode 100644 index 26786b373..000000000 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleCell.kt +++ /dev/null @@ -1,90 +0,0 @@ -package org.ooni.probe.ui.articles - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Text -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.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Blog -import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Finding -import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Recent -import ooniprobe.composeapp.generated.resources.Dashboard_Articles_Report -import ooniprobe.composeapp.generated.resources.Res -import org.jetbrains.compose.resources.stringResource -import org.ooni.probe.data.models.ArticleModel -import org.ooni.probe.data.models.ArticleModel.Source.Blog -import org.ooni.probe.data.models.ArticleModel.Source.Finding -import org.ooni.probe.data.models.ArticleModel.Source.Report -import org.ooni.probe.ui.shared.articleFormat -import org.ooni.probe.ui.theme.LocalCustomColors - -@Composable -fun ArticleCell( - article: ArticleModel, - onClick: () -> Unit, -) { - OutlinedCard( - onClick = onClick, - modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 8.dp), - ) { - Column( - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Text( - article.title, - style = MaterialTheme.typography.titleMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Row(verticalAlignment = Alignment.Bottom) { - Text( - stringResource( - when (article.source) { - Blog -> Res.string.Dashboard_Articles_Blog - Finding -> Res.string.Dashboard_Articles_Finding - Report -> Res.string.Dashboard_Articles_Report - }, - ), - style = MaterialTheme.typography.labelLarge - .copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 4.dp), - ) - - Text( - "•", - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(horizontal = 4.dp), - ) - Text( - article.time.articleFormat(), - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(top = 4.dp), - ) - if (article.isRecent) { - Text( - "•", - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(horizontal = 4.dp), - ) - Text( - stringResource(Res.string.Dashboard_Articles_Recent), - style = MaterialTheme.typography.labelLarge, - color = LocalCustomColors.current.success, - modifier = Modifier.padding(top = 4.dp), - ) - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesScreen.kt index 764dd638f..f30054ff7 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticlesScreen.kt @@ -78,7 +78,7 @@ fun ArticlesScreen( state = lazyListState, ) { items(state.articles, key = { it.url.value }) { article -> - ArticleCell( + ArticleCard( article = article, onClick = { onEvent(ArticlesViewModel.Event.ArticleClicked(article)) }, ) 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 61fdfbb47..40c63f98d 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 @@ -87,7 +87,7 @@ import org.ooni.probe.data.models.MeasurementStats import org.ooni.probe.data.models.Run import org.ooni.probe.data.models.RunBackgroundState import org.ooni.probe.shared.largeNumberShort -import org.ooni.probe.ui.articles.ArticleCell +import org.ooni.probe.ui.articles.ArticleCard import org.ooni.probe.ui.shared.IgnoreBatteryOptimizationDialog import org.ooni.probe.ui.shared.TestRunErrorMessages import org.ooni.probe.ui.shared.VerticalScrollbar @@ -486,7 +486,7 @@ private fun ArticlesSection( ) articles.forEach { article -> - ArticleCell( + ArticleCard( article = article, onClick = { onEvent(DashboardViewModel.Event.ArticleClicked(article)) }, ) diff --git a/composeApp/src/commonMain/sqldelight/migrations/15.sqm b/composeApp/src/commonMain/sqldelight/migrations/15.sqm index 881eb05b4..89cb5373f 100644 --- a/composeApp/src/commonMain/sqldelight/migrations/15.sqm +++ b/composeApp/src/commonMain/sqldelight/migrations/15.sqm @@ -3,5 +3,6 @@ CREATE TABLE Article( title TEXT NOT NULL, source TEXT NOT NULL, description TEXT, + image_url TEXT, time INTEGER NOT NULL ); diff --git a/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Article.sq b/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Article.sq index 357b64b91..5b799cf4b 100644 --- a/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Article.sq +++ b/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Article.sq @@ -3,6 +3,7 @@ CREATE TABLE Article( title TEXT NOT NULL, source TEXT NOT NULL, description TEXT, + image_url TEXT, time INTEGER NOT NULL ); @@ -10,10 +11,11 @@ insertOrReplace: INSERT OR REPLACE INTO Article ( url, title, - description, source, + description, + image_url, time -) VALUES (?,?,?,?,?); +) VALUES (?,?,?,?,?,?); selectAll: SELECT * FROM Article ORDER BY time DESC; diff --git a/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetRssFeedTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetRssFeedTest.kt index 498352a2a..011ef8c79 100644 --- a/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetRssFeedTest.kt +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetRssFeedTest.kt @@ -22,11 +22,49 @@ class GetRssFeedTest { assertEquals("https://ooni.org/post/2025-gg-omg-village/", url.value) assertEquals("Join us at the OMG Village at the Global Gathering 2025!", title) assertEquals(2025, time.year) + assertEquals("https://ooni.org/post/2025-gg-omg-village/images/omg-banner.png", imageUrl) } } companion object { - private const val RSS_FEED = - "Blog posts on OONI: Open Observatory of Network Interferencehttps://ooni.org/blog/Recent content in Blog posts on OONI: Open Observatory of Network InterferenceHugoenJoin us at the OMG Village at the Global Gathering 2025!https://ooni.org/post/2025-gg-omg-village/Mon, 01 Sep 2025 00:00:00 +0000https://ooni.org/post/2025-gg-omg-village/<p>Are you attending the upcoming <a href=\"https://wiki.digitalrights.community/index.php?title=Global_Gathering_2025\">Global Gathering</a> event in Estoril, Portugal? Are you interested in investigating internet shutdowns and censorship, and curious to learn more about the tools and open datasets that support this work?</p>" + private val RSS_FEED = """ + + + + Blog posts on OONI: Open Observatory of Network Interference + https://ooni.org/blog/ + Recent content in Blog posts on OONI: Open Observatory of Network Interference + Hugo + en + + + Join us at the OMG Village at the Global Gathering 2025! + https://ooni.org/post/2025-gg-omg-village/ + + Mon, 01 Sep 2025 00:00:00 +0000 + https://ooni.org/post/2025-gg-omg-village/ + + <div> + <a href="https://ooni.org/post/2025-gg-omg-village/images/omg-banner.png"> + <img + src="https://ooni.org/post/2025-gg-omg-village/images/omg-banner_hu_1e6c0cc0ca63d2d9.png" + + + srcset="https://ooni.org/post/2025-gg-omg-village/images/omg-banner_hu_79aea09410c8ceb7.png 2x" + + + title="OMG Village announcement" + + alt="OMG Village announcement" + + /> + </a> + + </div> + + + + + """.trimIndent() } } diff --git a/composeApp/src/commonTest/kotlin/org/ooni/testing/factories/ArticleModelFactory.kt b/composeApp/src/commonTest/kotlin/org/ooni/testing/factories/ArticleModelFactory.kt index 5e5d607a0..d1076d593 100644 --- a/composeApp/src/commonTest/kotlin/org/ooni/testing/factories/ArticleModelFactory.kt +++ b/composeApp/src/commonTest/kotlin/org/ooni/testing/factories/ArticleModelFactory.kt @@ -11,13 +11,15 @@ object ArticleModelFactory { fun build( url: ArticleModel.Url = ArticleModel.Url("https://example.org/${Random.nextInt()}"), title: String = "Title", - description: String? = null, time: LocalDateTime = LocalDate.today().atTime(0, 0), + description: String? = null, + imageUrl: String? = null, source: ArticleModel.Source = ArticleModel.Source.Blog, ) = ArticleModel( url = url, title = title, description = description, + imageUrl = imageUrl, time = time, source = source, ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 78ac03b17..8bb5bbd63 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,8 @@ sqldelight = "2.2.1" dataStoreVersion = "1.1.4" junitKtx = "1.3.0" mokoPermissions = "0.20.1" +ktor = "3.3.1" +coil = "3.3.0" [plugins] @@ -49,6 +51,11 @@ window-size = { module = "org.jetbrains.compose.material3:material3-window-size- back-handler = { module = "org.jetbrains.compose.ui:ui-backhandler", version.ref = "compose-plugin" } material-icons = { module = "org.jetbrains.compose.material:material-icons-core", version = "1.7.3" } dark-mode-detector = { module = "io.github.kdroidfilter:platformtools.darkmodedetector", version = "0.7.4" } +coil = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } +coil-network-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +coil-network-ios = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +coil-network-jvm = { module = "io.ktor:ktor-client-java", version.ref = "ktor" } # Preferences androidx-datastore-core-okio = { group = "androidx.datastore", name = "datastore-core-okio", version.ref = "dataStoreVersion" } @@ -134,26 +141,33 @@ tooling = [ "markdown", "kottie", "web-view", + "coil", + "coil-network", ] android = [ -# "android-oonimkall", + # "android-oonimkall", "android-activity", "android-fragment", "android-work", "sqldelight-android", "android-appcompat", + "coil-network-android", ] mobile = [ "moko-permissions-compose", "moko-permissions-notifications", ] +ios = [ + "coil-network-ios", +] desktop = [ "sqldelight-jvm", "androidx-datastore-core-jvm", "directories", "auto-launch", "pratanumandal-unique", - "desktop-oonimkall" + "desktop-oonimkall", + "coil-network-jvm", ] full = [ "sentry", From 7d59d03260a4dc48788e045d2ebcb35d74a17113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Tue, 4 Nov 2025 14:45:08 +0000 Subject: [PATCH 18/21] Show list of countries --- .../org/ooni/probe/AndroidApplication.kt | 7 +++ .../probe/data/models/MeasurementStats.kt | 2 +- .../data/repositories/NetworkRepository.kt | 8 +-- .../kotlin/org/ooni/probe/di/Dependencies.kt | 4 +- .../kotlin/org/ooni/probe/domain/GetStats.kt | 35 ++++++------ .../kotlin/org/ooni/probe/shared/NumberExt.kt | 9 ++-- .../probe/ui/dashboard/DashboardScreen.kt | 53 +++++++++++++++++-- .../sqldelight/org/ooni/probe/data/Network.sq | 4 +- .../org/ooni/probe/BuildDependencies.kt | 4 ++ .../org/ooni/probe/SetupDependencies.kt | 10 ++++ 10 files changed, 105 insertions(+), 31 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/org/ooni/probe/AndroidApplication.kt b/composeApp/src/androidMain/kotlin/org/ooni/probe/AndroidApplication.kt index 098b0e871..fd9539111 100644 --- a/composeApp/src/androidMain/kotlin/org/ooni/probe/AndroidApplication.kt +++ b/composeApp/src/androidMain/kotlin/org/ooni/probe/AndroidApplication.kt @@ -38,6 +38,8 @@ import org.ooni.probe.data.models.PlatformAction import org.ooni.probe.di.Dependencies import org.ooni.probe.shared.Platform import org.ooni.probe.shared.PlatformInfo +import java.util.Locale +import kotlin.text.ifEmpty /** * See link for `baseFileDir` https://github.com/ooni/probe-android/blob/5a11d1a36ec952aa1f355ba8db4129146139a5cc/engine/src/main/java/org/openobservatory/engine/Engine.java#L52 @@ -65,6 +67,7 @@ class AndroidApplication : Application() { isWebViewAvailable = ::isWebViewAvailable, flavorConfig = FlavorConfig(), proxyConfig = ProxyConfig(isPsiphonSupported = true), + getCountryNameByCode = ::getCountryNameByCode, ) } @@ -294,4 +297,8 @@ class AndroidApplication : Application() { }, ) } + + private fun getCountryNameByCode(countryCode: String) = + @Suppress("DEPRECATION") + Locale("", countryCode).displayCountry.ifEmpty { countryCode } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/MeasurementStats.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/MeasurementStats.kt index cbf691c84..3a18e093b 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/MeasurementStats.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/MeasurementStats.kt @@ -6,5 +6,5 @@ data class MeasurementStats( val measurementsMonth: Long, val measurementsTotal: Long, val networks: Long, - val countries: Long, + val countries: List, ) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/NetworkRepository.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/NetworkRepository.kt index 7697ef44f..f23c4e1c0 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/NetworkRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/NetworkRepository.kt @@ -5,6 +5,7 @@ import app.cash.sqldelight.coroutines.mapToList import app.cash.sqldelight.coroutines.mapToOne import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.withContext import org.ooni.engine.models.NetworkType import org.ooni.probe.Database @@ -63,11 +64,12 @@ class NetworkRepository( .asFlow() .mapToOne(backgroundContext) - fun countCountries(): Flow = + fun listCountries(): Flow> = database.networkQueries - .countCountries() + .selectCountries() .asFlow() - .mapToOne(backgroundContext) + .mapToList(backgroundContext) + .map { list -> list.mapNotNull { it.country_code } } suspend fun deleteWithoutResult() = withContext(backgroundContext) { 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 81ddcbc74..d6680c7fc 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -152,6 +152,7 @@ class Dependencies( private val batteryOptimization: BatteryOptimization, val flavorConfig: FlavorConfigInterface, val proxyConfig: ProxyConfig, + val getCountryNameByCode: (String) -> String, ) { // Common @@ -402,7 +403,8 @@ class Dependencies( GetStats( countMeasurementsFromStartTime = measurementRepository::countFromStartTime, countNetworkAsns = networkRepository::countAsns, - countNetworkCountries = networkRepository::countCountries, + getNetworkCountries = networkRepository::listCountries, + getCountryNameByCode = getCountryNameByCode, ) } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetStats.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetStats.kt index 0ce8ab9b3..12ef64651 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetStats.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetStats.kt @@ -14,28 +14,33 @@ import org.ooni.probe.shared.today class GetStats( private val countMeasurementsFromStartTime: (LocalDateTime) -> Flow, private val countNetworkAsns: () -> Flow, - private val countNetworkCountries: () -> Flow, + private val getNetworkCountries: () -> Flow>, + private val getCountryNameByCode: (String) -> String, ) { operator fun invoke(): Flow { val today = LocalDate.today() val startOfWeek = today.minus(today.dayOfWeek.isoDayNumber - 1, DateTimeUnit.DAY) val startOfMonth = today.minus(today.day - 1, DateTimeUnit.DAY) val startOfTotal = LocalDate.fromEpochDays(0) - return combine( - countMeasurementsFromStartTime(today.toDateTime()), - countMeasurementsFromStartTime(startOfWeek.toDateTime()), - countMeasurementsFromStartTime(startOfMonth.toDateTime()), - countMeasurementsFromStartTime(startOfTotal.toDateTime()), - countNetworkAsns(), - countNetworkCountries(), - ) { values -> + return combine( + combine( + countMeasurementsFromStartTime(today.toDateTime()), + countMeasurementsFromStartTime(startOfWeek.toDateTime()), + countMeasurementsFromStartTime(startOfMonth.toDateTime()), + countMeasurementsFromStartTime(startOfTotal.toDateTime()), + countNetworkAsns(), + ) { it }, + getNetworkCountries(), + ) { values, countries -> MeasurementStats( - values[0], - values[1], - values[2], - values[3], - values[4], - values[5], + measurementsToday = values[0], + measurementsWeek = values[1], + measurementsMonth = values[2], + measurementsTotal = values[3], + networks = values[4], + countries = countries + .map { getCountryNameByCode(it) } + .sorted(), ) } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/shared/NumberExt.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/shared/NumberExt.kt index aab9a162f..b8c5c7613 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/shared/NumberExt.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/shared/NumberExt.kt @@ -17,11 +17,12 @@ fun Double.format(decimalChars: Int = 2): String { return if (decimalValue == 0) absoluteValue.toString() else "$absoluteValue.$decimalValue" } -fun Long.largeNumberShort(): String { - if (this <= 0) return "0" +fun Number.largeNumberShort(): String { + val number = toLong() + if (number <= 0) return "0" val units = arrayOf("", "K", "M") - val digitGroups = (log10(this.toDouble()) / log10(1000.0)).toInt() - return (this / 1000.0.pow(digitGroups.toDouble())).withFractionalDigits() + units[digitGroups] + val digitGroups = (log10(toDouble()) / log10(1000.0)).toInt() + return (number / 1000.0.pow(digitGroups.toDouble())).withFractionalDigits() + units[digitGroups] } fun Double.withFractionalDigits(): String = if (this < 10) format(2) else format(1) 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 40c63f98d..bfa47a959 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 @@ -2,6 +2,7 @@ package org.ooni.probe.ui.dashboard import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ButtonDefaults @@ -28,6 +30,10 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -62,6 +68,7 @@ import ooniprobe.composeapp.generated.resources.Dashboard_TestsMoved_Description import ooniprobe.composeapp.generated.resources.Dashboard_TestsMoved_Title import ooniprobe.composeapp.generated.resources.Measurements_Failed import ooniprobe.composeapp.generated.resources.Modal_DisableVPN_Title +import ooniprobe.composeapp.generated.resources.Modal_OK import ooniprobe.composeapp.generated.resources.Res import ooniprobe.composeapp.generated.resources.TestResults_Overview_Websites_Blocked import ooniprobe.composeapp.generated.resources.TestResults_Overview_Websites_Tested @@ -396,6 +403,9 @@ private fun TestsMoved(onEvent: (DashboardViewModel.Event) -> Unit) { @Composable private fun StatsSection(stats: MeasurementStats?) { + var showCountriesDialog by remember { mutableStateOf(false) } + val countriesCount = stats?.countries?.size ?: 0 + Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -417,11 +427,12 @@ private fun StatsSection(stats: MeasurementStats?) { @Composable fun StatsEntry( key: String, - value: Long?, + value: Number?, + modifier: Modifier = Modifier, ) { Row( verticalAlignment = Alignment.Bottom, - modifier = Modifier.padding(horizontal = 8.dp).padding(top = 8.dp), + modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp), ) { Text( value?.largeNumberShort().orEmpty(), @@ -436,7 +447,7 @@ private fun StatsSection(stats: MeasurementStats?) { } FlowRow( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), ) { if (stats?.measurementsTotal == 0L) { Text(stringResource(Res.string.Dashboard_Stats_Empty)) @@ -452,15 +463,47 @@ private fun StatsSection(stats: MeasurementStats?) { ), stats?.networks, ) + StatsEntry( pluralStringResource( Res.plurals.Dashboard_Stats_Countries, - stats?.countries?.toInt() ?: 0, + countriesCount, ), - stats?.countries, + countriesCount, + modifier = Modifier.run { + if (countriesCount > 0) { + clickable { showCountriesDialog = true } + } else { + this + } + }, ) } } + + if (showCountriesDialog) { + AlertDialog( + onDismissRequest = { showCountriesDialog = false }, + title = { + Text( + pluralStringResource( + Res.plurals.Dashboard_Stats_Countries, + stats?.countries?.size ?: 0, + ), + ) + }, + text = { + Text( + stats?.countries.orEmpty().joinToString(", "), + ) + }, + confirmButton = { + TextButton(onClick = { showCountriesDialog = false }) { + Text(stringResource(Res.string.Modal_OK)) + } + }, + ) + } } @Composable diff --git a/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Network.sq b/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Network.sq index 7c5b3f3ac..225326424 100644 --- a/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Network.sq +++ b/composeApp/src/commonMain/sqldelight/org/ooni/probe/data/Network.sq @@ -39,6 +39,6 @@ LIMIT 1; countAsns: SELECT COUNT(DISTINCT Network.asn) FROM Network; -countCountries: -SELECT COUNT(DISTINCT Network.country_code) FROM Network +selectCountries: +SELECT DISTINCT Network.country_code FROM Network WHERE Network.country_code IS NOT NULL OR Network.country_code <> ''; diff --git a/composeApp/src/desktopMain/kotlin/org/ooni/probe/BuildDependencies.kt b/composeApp/src/desktopMain/kotlin/org/ooni/probe/BuildDependencies.kt index a85aa0551..048937d20 100644 --- a/composeApp/src/desktopMain/kotlin/org/ooni/probe/BuildDependencies.kt +++ b/composeApp/src/desktopMain/kotlin/org/ooni/probe/BuildDependencies.kt @@ -23,6 +23,7 @@ import java.io.File import java.net.URI import java.net.URLEncoder import java.nio.charset.StandardCharsets +import java.util.Locale private val projectDirectories = ProjectDirectories.from("org", "OONI", "Probe") private val osName = System.getProperty("os.name") @@ -57,6 +58,7 @@ val dependencies = Dependencies( cleanupLegacyDirectories = legacyDirectoryManager::cleanupLegacyDirectories, flavorConfig = DesktopFlavorConfig(), proxyConfig = ProxyConfig(isPsiphonSupported = false), + getCountryNameByCode = ::getCountryNameByCode, ) private fun buildPlatformInfo(): PlatformInfo { @@ -169,6 +171,8 @@ fun sendMail(action: PlatformAction.Mail): Boolean = false } +private fun getCountryNameByCode(countryCode: String) = Locale("", countryCode).displayCountry.ifEmpty { countryCode } + private fun buildMailUri(action: PlatformAction.Mail): URI { val subject = URLEncoder.encode(action.subject, StandardCharsets.UTF_8).replace("+", "%20") val body = URLEncoder diff --git a/composeApp/src/iosMain/kotlin/org/ooni/probe/SetupDependencies.kt b/composeApp/src/iosMain/kotlin/org/ooni/probe/SetupDependencies.kt index 667cad824..ff191d2f8 100644 --- a/composeApp/src/iosMain/kotlin/org/ooni/probe/SetupDependencies.kt +++ b/composeApp/src/iosMain/kotlin/org/ooni/probe/SetupDependencies.kt @@ -47,7 +47,9 @@ import platform.Foundation.NSURL import platform.Foundation.NSUTF8StringEncoding import platform.Foundation.NSUserDomainMask import platform.Foundation.characterDirectionForLanguage +import platform.Foundation.currentLocale import platform.Foundation.dateByAddingTimeInterval +import platform.Foundation.localizedStringForCountryCode import platform.Foundation.stringWithContentsOfFile import platform.MessageUI.MFMailComposeResult import platform.MessageUI.MFMailComposeViewController @@ -98,6 +100,7 @@ class SetupDependencies( isWebViewAvailable = { true }, flavorConfig = FlavorConfig(), proxyConfig = ProxyConfig(isPsiphonSupported = true), + getCountryNameByCode = ::getCountryNameByCode, ) private val operationsManager = OperationsManager(dependencies, backgroundRunner) @@ -421,4 +424,11 @@ class SetupDependencies( } private fun findCurrentViewController(): UIViewController? = UIApplication.sharedApplication.keyWindow?.rootViewController + + private fun getCountryNameByCode(countryCode: String) = + NSLocale + .currentLocale() + .localizedStringForCountryCode(countryCode) + ?.ifEmpty { null } + ?: countryCode } From a4ef69a5d43e9b35a02ea36cc3b9424d3301c916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Mon, 10 Nov 2025 14:41:04 +0000 Subject: [PATCH 19/21] Fix Locale deprecation --- .../kotlin/org/ooni/probe/AndroidApplication.kt | 8 ++++++-- .../kotlin/org/ooni/probe/BuildDependencies.kt | 8 +++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/org/ooni/probe/AndroidApplication.kt b/composeApp/src/androidMain/kotlin/org/ooni/probe/AndroidApplication.kt index fd9539111..c0edfdf18 100644 --- a/composeApp/src/androidMain/kotlin/org/ooni/probe/AndroidApplication.kt +++ b/composeApp/src/androidMain/kotlin/org/ooni/probe/AndroidApplication.kt @@ -299,6 +299,10 @@ class AndroidApplication : Application() { } private fun getCountryNameByCode(countryCode: String) = - @Suppress("DEPRECATION") - Locale("", countryCode).displayCountry.ifEmpty { countryCode } + Locale + .Builder() + .setRegion(countryCode) + .build() + .displayCountry + .ifEmpty { countryCode } } diff --git a/composeApp/src/desktopMain/kotlin/org/ooni/probe/BuildDependencies.kt b/composeApp/src/desktopMain/kotlin/org/ooni/probe/BuildDependencies.kt index 048937d20..92e6724fb 100644 --- a/composeApp/src/desktopMain/kotlin/org/ooni/probe/BuildDependencies.kt +++ b/composeApp/src/desktopMain/kotlin/org/ooni/probe/BuildDependencies.kt @@ -171,7 +171,13 @@ fun sendMail(action: PlatformAction.Mail): Boolean = false } -private fun getCountryNameByCode(countryCode: String) = Locale("", countryCode).displayCountry.ifEmpty { countryCode } +private fun getCountryNameByCode(countryCode: String) = + Locale + .Builder() + .setRegion(countryCode) + .build() + .displayCountry + .ifEmpty { countryCode } private fun buildMailUri(action: PlatformAction.Mail): URI { val subject = URLEncoder.encode(action.subject, StandardCharsets.UTF_8).replace("+", "%20") From 4853eeba862d4a8d81c88e3f86a3c88a6e93bbc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Tue, 11 Nov 2025 13:26:05 +0000 Subject: [PATCH 20/21] UI improvements --- .../org/ooni/probe/ui/descriptors/DescriptorsScreen.kt | 6 +++--- .../src/ooniMain/kotlin/org/ooni/probe/ui/Colors.kt | 8 ++++---- gradle/libs.versions.toml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt index d838a5d77..abf276365 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt @@ -157,7 +157,7 @@ fun DescriptorsScreen( val lazyListState = rememberLazyListState() LazyColumn( modifier = Modifier.testTag("Descriptors-List"), - contentPadding = PaddingValues(vertical = 16.dp), + contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp), state = lazyListState, ) { val allSectionsHaveValues = state.sections.all { it.descriptors.any() } @@ -293,8 +293,8 @@ private fun CheckUpdatesButton( @Composable private fun Descriptor.matches(filter: String?) = filter == null || - title().contains(filter, ignoreCase = true) || - shortDescription()?.contains(filter, ignoreCase = true) == true + title().contains(filter.trim(), ignoreCase = true) || + shortDescription()?.contains(filter.trim(), ignoreCase = true) == true @Preview @Composable diff --git a/composeApp/src/ooniMain/kotlin/org/ooni/probe/ui/Colors.kt b/composeApp/src/ooniMain/kotlin/org/ooni/probe/ui/Colors.kt index b7da1440b..74759fdf8 100644 --- a/composeApp/src/ooniMain/kotlin/org/ooni/probe/ui/Colors.kt +++ b/composeApp/src/ooniMain/kotlin/org/ooni/probe/ui/Colors.kt @@ -23,7 +23,7 @@ val onBackgroundLight = Color(0xFF000000) val surfaceLight = Color(0xFFF7F9FF) val onSurfaceLight = Color(0xFF000000) val surfaceVariantLight = Color(0xFFF0F0F0) -val onSurfaceVariantLight = Color(0xFF777777) +val onSurfaceVariantLight = Color(0xFF444444) val outlineLight = Color(0xFF74777F) val outlineVariantLight = Color(0xFFC4C6D0) val scrimLight = Color(0xFF000000) @@ -55,11 +55,11 @@ val onErrorDark = Color(0xFF571E1A) val errorContainerDark = Color(0xFF73332F) val onErrorContainerDark = Color(0xFFFFDAD6) val backgroundDark = Color(0xFF101418) -val onBackgroundDark = Color(0xFFE0E2E8) +val onBackgroundDark = Color(0xFFF3F3F3) val surfaceDark = Color(0xFF101418) -val onSurfaceDark = Color(0xFFE0E2E8) +val onSurfaceDark = Color(0xFFF3F3F3) val surfaceVariantDark = Color(0xFF333333) -val onSurfaceVariantDark = Color(0xFFCCCCCC) +val onSurfaceVariantDark = Color(0xFFDDDDDD) val outlineDark = Color(0xFF8E9099) val outlineVariantDark = Color(0xFF44474E) val scrimDark = Color(0xFF000000) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8bb5bbd63..fc6dd11d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ sqldelight = "2.2.1" dataStoreVersion = "1.1.4" junitKtx = "1.3.0" mokoPermissions = "0.20.1" -ktor = "3.3.1" +ktor = "3.3.2" coil = "3.3.0" [plugins] From 9c4380d9613d6874b032bd0ffab377f708e5993e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Tue, 11 Nov 2025 18:26:38 +0000 Subject: [PATCH 21/21] Increase java memory --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 5f4b4b383..2a8370412 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ kotlin.code.style=official #Gradle -org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx4g" +org.gradle.jvmargs=-Xmx5g -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx5g" org.gradle.caching=true #Android