From 10090724254d309d08dcea51ded077f296b21c02 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/11] 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 ddf591ecb062cbbe44dd2414128265df08409f23 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/11] 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 92906005356dc697442725d4f4c09dbbc8f41c6f 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/11] 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 5356c9a2f..6aab25247 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( @@ -77,7 +75,7 @@ class RunDescriptors( } catch (e: 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 143acc4df25971c6287c57f1c146022f0f42bb78 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/11] 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 9b4dcbfd97ce6795b747866eda6bcdea326fcde6 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/11] 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 52aba99dc4d6d74ecf3f283fbfa0c3dd5d260fa3 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/11] 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 57684a7ae..f3acaeb85 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 @@ -110,7 +111,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 6ba4c163fe7fbf1e2400ff5a7da4dd02ab1c1fe3 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/11] 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 79ad9b90789735b187a4dd207ef2a760c171e7eb 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/11] 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 9dd1a941af36d1f8be7a8f1481d03a44042c2041 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/11] 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 3c858b59ea7ca65c1e8a30769e03570809e16e4c Mon Sep 17 00:00:00 2001 From: Norbel AMBANUMBEN Date: Mon, 27 Oct 2025 14:01:12 +0000 Subject: [PATCH 10/11] 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 6ae938c88bccdc1f4629d6e42a9c6f8e4e2f5b93 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/11] 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) + } +}