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/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 ff003a8bd..7a665e178 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 @@ -38,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 @@ -56,6 +58,7 @@ class DescriptorsTest { fun setUp() = runTest { skipOnboarding() + disableRefreshArticles() } @Test @@ -82,8 +85,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 +136,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 +150,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/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 0c52e8663..ac1dfc0dc 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,9 @@ 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.Measurement_Title import ooniprobe.composeapp.generated.resources.OONIRun_Run import ooniprobe.composeapp.generated.resources.Res @@ -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() } @@ -66,7 +68,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 +89,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 +110,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 +131,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 +152,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/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/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..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,9 +2,19 @@ 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.setValueByKey(SettingsKey.FIRST_RUN, false) + preferences.setValuesByKey( + listOf( + SettingsKey.FIRST_RUN to false, + SettingsKey.TESTS_MOVED_NOTICE to true, + ), + ) +} + +suspend fun disableRefreshArticles() { + preferences.setValueByKey(SettingsKey.LAST_ARTICLES_REFRESH, Clock.System.now().epochSeconds) } suspend fun defaultSettings() { 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/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/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/drawable/ic_open_external.xml b/composeApp/src/commonMain/composeResources/drawable/ic_open_external.xml new file mode 100644 index 000000000..363729716 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_open_external.xml @@ -0,0 +1,11 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index 848b3f552..17b02e14b 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,16 @@ Finishing the currently pending tests, please wait… Proxy in use + Last Results + See results + + 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 @@ -21,9 +31,26 @@ Run %1$d tests + Your Measurements + + Network + Networks + + + Country + Countries + + Start running tests to see your statistics here. + + OONI News + Blog Post + Finding + Report + Read More + Recent + OONI Tests OONI Run Links - Run finished. Tap to view results. Created by %1$s on %2$s Uninstall Link Previous revisions @@ -52,8 +79,10 @@ The app update has just been downloaded Restart - + + Tests + Search tests Websites Instant Messaging Performance @@ -71,11 +100,9 @@ Tor Test Signal Test - + - Test Results - Test Results - Tests + Results Networks Data Usage @@ -358,6 +385,8 @@ + Enter a OONI Run Link URL + The URL is not a valid OONI Run Link Test Settings Install New Link Install updates automatically @@ -367,8 +396,7 @@ Install & Run Unsupported URL Link Loading - Error - Link installation cancelled + Error loading link UPDATES Check for Updates Auto-run is disabled. Please enable it to run tests automatically. @@ -436,9 +464,14 @@ Clear Next Previous + Dismiss + Search Today Yesterday + Week + Month + Total %1$s ago %1$d second diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt index 01ff8e68f..9546e61cb 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() @@ -133,9 +134,7 @@ fun App( LaunchedEffect(deepLink) { when (deepLink) { is DeepLink.AddDescriptor -> { - navController.navigate( - Screen.AddDescriptor(deepLink.id.toLongOrNull() ?: return@LaunchedEffect), - ) + navController.navigate(Screen.AddDescriptor(deepLink.id)) onDeeplinkHandled() } @@ -169,4 +168,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/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/config/OrganizationConfig.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/config/OrganizationConfig.kt index 3a008cf5a..d76aab73c 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,8 @@ interface OrganizationConfigInterface { val updateDescriptorTaskId: String val hasWebsitesDescriptor: Boolean val donateUrl: String? + val hasOoniNews: Boolean + val canInstallDescriptors: Boolean val ooniApiBaseUrl get() = BuildTypeDefaults.ooniApiBaseUrl val ooniRunDomain get() = BuildTypeDefaults.ooniRunDomain diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/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/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/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..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 @@ -67,6 +67,9 @@ enum class SettingsKey( FIRST_RUN("first_run"), CHOSEN_WEBSITES("chosen_websites"), 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/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/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/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/data/repositories/PreferenceRepository.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/PreferenceRepository.kt index ac65480b1..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 @@ -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,10 @@ class PreferenceRepository( SettingsKey.DELETE_OLD_RESULTS_THRESHOLD, -> PreferenceKey.IntKey(intPreferencesKey(preferenceKey)) + SettingsKey.LAST_RUN_DISMISSED, + SettingsKey.LAST_ARTICLES_REFRESH, + -> 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 e189ec077..81ddcbc74 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 @@ -48,8 +50,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 +60,9 @@ 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.GetStats 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 @@ -76,6 +74,9 @@ 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 import org.ooni.probe.domain.descriptors.DeleteTestDescriptor @@ -90,15 +91,25 @@ 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 +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 +import org.ooni.probe.ui.descriptor.add.AddDescriptorUrlViewModel import org.ooni.probe.ui.descriptor.add.AddDescriptorViewModel import org.ooni.probe.ui.descriptor.review.ReviewUpdatesViewModel import org.ooni.probe.ui.descriptor.websites.DescriptorWebsitesViewModel +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 @@ -154,6 +165,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) @@ -291,6 +305,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, @@ -327,6 +347,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, @@ -372,6 +398,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 { @@ -389,9 +422,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, @@ -467,7 +497,27 @@ class Dependencies( private val shouldShowVpnWarning by lazy { ShouldShowVpnWarning(preferenceRepository, networkTypeFinder::invoke) } - val runBackgroundStateManager by lazy { RunBackgroundStateManager(resultRepository.getLatest()) } + val refreshArticles by lazy { + RefreshArticles( + 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() } private val undoRejectedDescriptorUpdate by lazy { UndoRejectedDescriptorUpdate( updateDescriptorRejectedRevision = testDescriptorRepository::updateRejectedRevision, @@ -546,6 +596,35 @@ class Dependencies( startBackgroundRun = startSingleRunInner, ) + fun addDescriptorUrlViewModel( + onClose: () -> Unit, + goToAddDescriptor: (InstalledTestDescriptorModel.Id) -> Unit, + ) = AddDescriptorUrlViewModel( + onClose = onClose, + goToAddDescriptor = goToAddDescriptor, + ) + + 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 = articleRepository::list, + refreshArticles = refreshArticles::invoke, + canPullToRefresh = platformInfo.canPullToRefresh, + ) + fun chooseWebsitesViewModel( initialUrl: String?, onBack: () -> Unit, @@ -564,25 +643,45 @@ class Dependencies( goToResults: () -> Unit, goToRunningTest: () -> Unit, goToRunTests: () -> Unit, - goToDescriptor: (String) -> Unit, - goToReviewDescriptorUpdates: (List?) -> Unit, + goToTests: () -> Unit, + goToTestSettings: () -> Unit, + goToArticles: () -> Unit, + goToArticle: (ArticleModel.Url) -> Unit, ) = DashboardViewModel( goToOnboarding = goToOnboarding, goToResults = goToResults, goToRunningTest = goToRunningTest, goToRunTests = goToRunTests, - goToDescriptor = goToDescriptor, + goToTests = goToTests, + goToTestSettings = goToTestSettings, + goToArticles = goToArticles, + goToArticle = goToArticle, getFirstRun = getFirstRun::invoke, + observeRunBackgroundState = runBackgroundStateManager::observeState, + observeTestRunErrors = runBackgroundStateManager::observeErrors, + shouldShowVpnWarning = shouldShowVpnWarning::invoke, + getAutoRunSettings = getAutoRunSettings::invoke, + getLastRun = getLastRun::invoke, + dismissLastRun = dismissLastRun::invoke, + getPreference = preferenceRepository::getValueByKey, + setPreference = preferenceRepository::setValueByKey, + getStats = getStats::invoke, + getArticles = articleRepository::list, + batteryOptimization = batteryOptimization, + ) + + fun descriptorsViewModel( + goToDescriptor: (String) -> Unit, + goToReviewDescriptorUpdates: (List?) -> Unit, + goToAddDescriptorUrl: () -> Unit, + ) = DescriptorsViewModel( + goToDescriptor = goToDescriptor, goToReviewDescriptorUpdates = goToReviewDescriptorUpdates, + goToAddDescriptorUrl = goToAddDescriptorUrl, getTestDescriptors = getTestDescriptors::latest, - observeRunBackgroundState = runBackgroundStateManager.observeState(), - observeTestRunErrors = runBackgroundStateManager.observeErrors(), - shouldShowVpnWarning = shouldShowVpnWarning::invoke, startDescriptorsUpdates = startDescriptorsUpdate, dismissDescriptorsUpdateNotice = dismissDescriptorReviewNotice::invoke, observeDescriptorUpdateState = descriptorUpdateStateManager::observe, - getAutoRunSettings = getAutoRunSettings::invoke, - batteryOptimization = batteryOptimization, canPullToRefresh = platformInfo.canPullToRefresh, getPreference = preferenceRepository::getValueByKey, setPreference = preferenceRepository::setValueByKey, @@ -672,7 +771,6 @@ class Dependencies( getNetworks = networkRepository::list, deleteResultsByFilter = deleteResults::byFilter, deleteResults = deleteResults::byIds, - markJustFinishedTestAsSeen = markJustFinishedTestAsSeen::invoke, markAsViewed = resultRepository::markAllAsViewed, ) @@ -796,6 +894,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/domain/BootstrapPreferences.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/BootstrapPreferences.kt index 6e967a2b7..31aabffc4 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, @@ -33,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/domain/GetSettings.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetSettings.kt index e93677600..8e0df5a42 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetSettings.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetSettings.kt @@ -68,10 +68,11 @@ 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.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/domain/MarkJustFinishedTestAsSeen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/MarkJustFinishedTestAsSeen.kt deleted file mode 100644 index d4af3ec3f..000000000 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/MarkJustFinishedTestAsSeen.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.ooni.probe.domain - -import org.ooni.probe.data.models.RunBackgroundState - -class MarkJustFinishedTestAsSeen( - private val setRunBackgroundState: ((RunBackgroundState) -> RunBackgroundState) -> Unit, -) { - operator fun invoke() { - setRunBackgroundState { state -> - if (state is RunBackgroundState.Idle && state.justFinishedTest) { - state.copy(justFinishedTest = false) - } else { - state - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunBackgroundStateManager.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunBackgroundStateManager.kt index 45f536a4c..2b7f38966 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunBackgroundStateManager.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunBackgroundStateManager.kt @@ -1,37 +1,24 @@ package org.ooni.probe.domain -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update -import org.ooni.probe.data.models.ResultModel -import org.ooni.probe.data.models.TestRunError import org.ooni.probe.data.models.RunBackgroundState +import org.ooni.probe.data.models.TestRunError -class RunBackgroundStateManager( - private val getLatestResult: Flow, -) { - private val state = MutableStateFlow(RunBackgroundState.Idle()) +class RunBackgroundStateManager { + private val state = MutableStateFlow(RunBackgroundState.Idle) private val errors = MutableSharedFlow(extraBufferCapacity = 1) private val cancelListeners = mutableListOf<() -> Unit>() // State - fun observeState() = - state - .asStateFlow() - .onStart { - state.update { value -> - if (value !is RunBackgroundState.Idle || value.lastTestAt != null) return@update value - RunBackgroundState.Idle(lastTestAt = getLatestResult.first()?.startTime) - } - } - - fun updateState(update: (RunBackgroundState) -> RunBackgroundState) = state.update(update) + fun observeState() = state.asSharedFlow() + + fun updateState(update: (RunBackgroundState) -> RunBackgroundState) { + state.update(update) + } // Errors diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunDescriptors.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunDescriptors.kt index 3e8cf64e0..e0069eb0a 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunDescriptors.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunDescriptors.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.last import kotlinx.coroutines.launch -import kotlinx.datetime.LocalDateTime import org.ooni.engine.Engine.MkException import org.ooni.engine.models.EnginePreferences import org.ooni.engine.models.NetworkType @@ -22,7 +21,6 @@ import org.ooni.probe.data.models.TestRunError import org.ooni.probe.data.models.UrlModel import org.ooni.probe.domain.proxy.TestProxy import org.ooni.probe.shared.monitoring.Instrumentation -import org.ooni.probe.shared.now import kotlin.time.Duration class RunDescriptors( @@ -78,7 +76,7 @@ class RunDescriptors( } catch (_: Exception) { // Exceptions were logged in the Engine } finally { - setRunBackgroundState { RunBackgroundState.Idle(LocalDateTime.now(), true) } + setRunBackgroundState { RunBackgroundState.Idle } finishInProgressData() } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt new file mode 100644 index 000000000..5c30c5e6b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/GetFindings.kt @@ -0,0 +1,69 @@ +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.config.OrganizationConfig +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, + val json: Json, +) : RefreshArticles.Source { + override suspend operator fun invoke(): Result, Exception> { + return httpDo( + "GET", + "${OrganizationConfig.ooniApiBaseUrl}/api/v1/incidents/search", + TaskOrigin.OoniRun, + ).mapError { Exception("Failed to get findings", it) } + .flatMap { response -> + if (response.isNullOrBlank()) return@flatMap Failure(Exception("Empty response")) + + 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("${OrganizationConfig.explorerUrl}/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() + + @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..a2bc64338 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/articles/RefreshArticles.kt @@ -0,0 +1,64 @@ +package org.ooni.probe.domain.articles + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import org.ooni.engine.models.Result +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 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> + } + + suspend operator fun invoke(skipIntervalCheck: Boolean = false) { + if (!OrganizationConfig.hasOoniNews) return + + val lastCheck = (getPreference(SettingsKey.LAST_ARTICLES_REFRESH).first() as? Long) + ?.let { Instant.fromEpochSeconds(it) } + if ( + !skipIntervalCheck && + lastCheck != null && + Clock.System.now() - lastCheck < MIN_INTERVAL + ) { + return + } + + val responses = sources + .map { + coroutineScope { async { it() } } + }.awaitAll() + + responses.forEach { response -> + response.onFailure { + Logger.w("Failed to get article source", it) + } + } + + if (responses.all { it is Success }) { + refreshArticlesInDatabase( + responses.mapNotNull { it.get() }.flatten(), + ) + } + + setPreference(SettingsKey.LAST_ARTICLES_REFRESH, Clock.System.now().epochSeconds) + } + + companion object { + private val MIN_INTERVAL = 1.days + } +} 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/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/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..58ab97e4e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleScreen.kt @@ -0,0 +1,154 @@ +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 ooniprobe.composeapp.generated.resources.ic_open_external +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.ooni.probe.ui.shared.NavigationBackButton +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.OpenExternal) }) { + Icon( + painterResource(Res.drawable.ic_open_external), + contentDescription = null, + ) + } + 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()), + onDisallowedUrl = { onEvent(ArticleViewModel.Event.OutsideLinkClicked(it)) }, + ) + + 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..fdbabc014 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/articles/ArticleViewModel.kt @@ -0,0 +1,80 @@ +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 + "?enable-embedded-view=true") + } else { + launchAction(PlatformAction.OpenUrl(url.value)) + onBack() + } + } + + events + .filterIsInstance() + .onEach { onBack() } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { launchAction(PlatformAction.OpenUrl(url.value)) } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { launchAction(PlatformAction.Share(url.value)) } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { launchAction(PlatformAction.OpenUrl(it.url)) } + .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 OpenExternal : Event + + data object ShareUrl : Event + + data class OutsideLinkClicked( + val url: String, + ) : 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..764dd638f --- /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.value }) { 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..341b55289 --- /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 (Boolean) -> 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(true) + _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/DashboardCard.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt new file mode 100644 index 000000000..86011c3ae --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardCard.kt @@ -0,0 +1,78 @@ +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.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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, +) { + OutlinedCard( + colors = CardDefaults.outlinedCardColors( + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + disabledContentColor = MaterialTheme.colorScheme.onSurface, + ), + modifier = modifier.padding(horizontal = 16.dp, vertical = 4.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 d2762c304..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 @@ -2,185 +2,180 @@ 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.FlowRow 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.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 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.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.testTag +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 -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.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_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 +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 +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_keyboard_arrow_down -import ooniprobe.composeapp.generated.resources.ic_keyboard_arrow_up +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 +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 +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.DescriptorType -import org.ooni.probe.data.models.DescriptorUpdateOperationState +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.UpdateProgressStatus 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.cardTitle +import org.ooni.probe.ui.theme.dashboardSectionTitle @Composable 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), - ) - } + 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), + ) + } - Box { - Image( - painterResource(Res.drawable.dashboard_arc), - contentDescription = null, - contentScale = ContentScale.FillBounds, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primaryContainer), - modifier = Modifier.fillMaxWidth().height(32.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(), + ) { + RunBackgroundStateSection(state.runBackgroundState, onEvent) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), - ) { - RunBackgroundStateSection(state.runBackgroundState, onEvent) + if (state.runBackgroundState is RunBackgroundState.Idle) { + AutoRunButton(isAutoRunEnabled = state.isAutoRunEnabled, onEvent) } } + } - if (state.showVpnWarning) { - VpnWarning() - } + // Scrollable Content + Box(Modifier.fillMaxSize()) { + val scrollState = rememberScrollState() + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .verticalScroll(scrollState) + .padding(bottom = 16.dp) + .fillMaxSize(), + ) { + if (state.showVpnWarning) { + VpnWarning() + } - 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), - ) - }, - ) - } - } + if (state.runBackgroundState is RunBackgroundState.Idle && state.lastRun != null) { + LastRun(state.lastRun, onEvent) } - 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) }, - ) - } + if (state.showTestsMovedNotice) { + TestsMoved(onEvent) + } - PullToRefreshDefaults.Indicator( - modifier = Modifier.align(Alignment.TopCenter), - isRefreshing = state.isRefreshing, - state = pullRefreshState, - ) + StatsSection(state.stats) + ArticlesSection(state.articles, state.showReadMoreArticles, onEvent) + } + VerticalScrollbar(state = scrollState, modifier = Modifier.align(Alignment.CenterEnd)) + } } TestRunErrorMessages( @@ -197,56 +192,60 @@ fun DashboardScreen( LifecycleResumeEffect(Unit) { onEvent(DashboardViewModel.Event.Resumed) - onPauseOrDispose { - onEvent(DashboardViewModel.Event.Paused) - } + onPauseOrDispose { onEvent(DashboardViewModel.Event.Paused) } } } @Composable -private fun TestDescriptorSectionTitle( - type: DescriptorType, - isCollapsed: Boolean, - state: DashboardViewModel.State, +private fun AutoRunButton( + isAutoRunEnabled: Boolean, 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), + TextButton( + onClick = { onEvent(DashboardViewModel.Event.AutoRunClicked) }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface), + modifier = Modifier.padding(top = 4.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, + 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) } } } @@ -269,23 +268,241 @@ private fun VpnWarning() { } @Composable -private fun CheckUpdatesButton( - enabled: Boolean, +private fun LastRun( + run: Run, onEvent: (DashboardViewModel.Event) -> Unit, ) { - TextButton( - onClick = { onEvent(DashboardViewModel.Event.FetchUpdatedDescriptors) }, - enabled = enabled, - contentPadding = PaddingValues( - horizontal = 8.dp, - vertical = 4.dp, + DashboardCard( + title = { + Text( + stringResource(Res.string.Dashboard_LastResults), + style = MaterialTheme.typography.cardTitle, + ) + 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( + stringResource(Res.string.Common_Dismiss), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.66f), + ) + } + }, + endActions = { + TextButton(onClick = { onEvent(DashboardViewModel.Event.SeeResultsClicked) }) { + Text(stringResource(Res.string.Dashboard_LastResults_SeeResults)) + } + }, + 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, ), - modifier = Modifier.defaultMinSize(minHeight = 32.dp), + border = AssistChipDefaults.assistChipBorder(true), + modifier = modifier, + ) +} + +@Composable +private fun TestsMoved(onEvent: (DashboardViewModel.Event) -> Unit) { + DashboardCard( + title = { + Text( + stringResource(Res.string.Dashboard_TestsMoved_Title), + style = MaterialTheme.typography.cardTitle, + ) + }, + 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), + ) +} + +@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.DescriptorUpdate_CheckUpdates), - style = MaterialTheme.typography.labelMedium, + 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, + ) + } + } +} + +@Composable +private fun ArticlesSection( + articles: List, + showReadMore: Boolean, + onEvent: (DashboardViewModel.Event) -> Unit, +) { + if (!OrganizationConfig.hasOoniNews || articles.isEmpty()) 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(), + ) + } } } 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..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 @@ -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 @@ -17,12 +15,10 @@ 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.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.MeasurementStats +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 @@ -34,25 +30,26 @@ class DashboardViewModel( goToResults: () -> Unit, goToRunningTest: () -> Unit, goToRunTests: () -> Unit, - goToDescriptor: (String) -> Unit, + goToTests: () -> Unit, + goToTestSettings: () -> Unit, + goToArticles: () -> Unit, + goToArticle: (ArticleModel.Url) -> Unit, getFirstRun: () -> Flow, - goToReviewDescriptorUpdates: (List?) -> Unit, - getTestDescriptors: () -> Flow>, - observeRunBackgroundState: Flow, - observeTestRunErrors: Flow, + observeRunBackgroundState: () -> Flow, + observeTestRunErrors: () -> Flow, shouldShowVpnWarning: suspend () -> Boolean, - observeDescriptorUpdateState: () -> Flow, - startDescriptorsUpdates: suspend (List?) -> Unit, - dismissDescriptorsUpdateNotice: () -> Unit, getAutoRunSettings: () -> Flow, + getLastRun: () -> Flow, + dismissLastRun: suspend () -> Unit, + getPreference: (SettingsKey) -> Flow, + setPreference: suspend (SettingsKey, Any) -> Unit, + getStats: () -> Flow, + getArticles: () -> 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 { @@ -62,70 +59,111 @@ class DashboardViewModel( .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(isAutoRunEnabled = autoRunParameters is AutoRunParameters.Enabled) } }.launchIn(viewModelScope) - observeDescriptorUpdateState() - .onEach { updates -> + getAutoRunSettings() + .take(1) + .onEach { autoRunParameters -> _state.update { it.copy( - availableUpdates = updates.availableUpdates.toList(), - descriptorsUpdateOperationState = updates.operationState, + showIgnoreBatteryOptimizationNotice = + autoRunParameters is AutoRunParameters.Enabled && + batteryOptimization.isSupported && + !batteryOptimization.isIgnoring, ) } }.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 + 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) + + getPreference(SettingsKey.TESTS_MOVED_NOTICE) + .onEach { preference -> + _state.update { it.copy(showTestsMovedNotice = preference != true) } + }.launchIn(viewModelScope) + + getStats() + .onEach { stats -> + _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() + .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) + events + .filterIsInstance() + .onEach { dismissLastRun() } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { goToTests() } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .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 -> _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 +177,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,104 +196,55 @@ 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 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 availableUpdates: List = emptyList(), - val descriptorsUpdateOperationState: DescriptorUpdateOperationState = DescriptorUpdateOperationState.Idle, + val lastRun: Run? = null, 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 - } + val showTestsMovedNotice: Boolean = false, + ) sealed interface Event { data object Resumed : Event data object Paused : Event - data object RunTestsClick : Event + data object RunTestsClicked : Event - data object RunningTestClick : Event + data object RunningTestClicked : Event - data object SeeResultsClick : Event + data object AutoRunClicked : Event - data class ErrorDisplayed( - val error: TestRunError, - ) : Event + data object SeeResultsClicked : Event - data class DescriptorClicked( - val descriptor: Descriptor, - ) : Event + data object DismissResultsClicked : Event - data class ToggleSection( - val type: DescriptorType, - ) : Event + data object SeeTestsClicked : Event - data class UpdateDescriptorClicked( - val descriptor: Descriptor, - ) : Event + data object DismissTestsMovedClicked : Event - data object FetchUpdatedDescriptors : Event + data class ArticleClicked( + val article: ArticleModel, + ) : Event - data object ReviewUpdatesClicked : Event + data object ReadMoreArticlesClicked : Event - data object CancelUpdatesClicked : Event + data class ErrorDisplayed( + val error: TestRunError, + ) : 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 + private const val ARTICLES_TO_SHOW = 3 } } 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..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 @@ -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), ) { @@ -252,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/descriptor/add/AddDescriptorMessage.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorMessage.kt new file mode 100644 index 000000000..ecb5de12b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorMessage.kt @@ -0,0 +1,35 @@ +package org.ooni.probe.ui.descriptor.add + +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import ooniprobe.composeapp.generated.resources.AddDescriptor_Toasts_Installed +import ooniprobe.composeapp.generated.resources.LoadingScreen_Runv2_Failure +import ooniprobe.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.getString +import org.ooni.probe.LocalSnackbarHostState + +@Composable +fun AddDescriptorMessage( + message: AddDescriptorViewModel.Message?, + onMessageDisplayed: (AddDescriptorViewModel.Message) -> Unit = { }, +) { + val snackbarHostState = LocalSnackbarHostState.current ?: return + LaunchedEffect(message) { + val message = message ?: return@LaunchedEffect + val result = snackbarHostState.showSnackbar( + getString( + when (message) { + AddDescriptorViewModel.Message.AddDescriptorSuccess -> + Res.string.AddDescriptor_Toasts_Installed + + AddDescriptorViewModel.Message.FailedToFetch -> + Res.string.LoadingScreen_Runv2_Failure + }, + ), + ) + if (result == SnackbarResult.Dismissed) { + onMessageDisplayed(message) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorScreen.kt index 02904b21d..4243aa398 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorScreen.kt @@ -46,7 +46,6 @@ import org.ooni.probe.ui.dashboard.TestDescriptorLabel import org.ooni.probe.ui.descriptor.isSingleWebConnectivityTest import org.ooni.probe.ui.run.TestItem import org.ooni.probe.ui.shared.NavigationCloseButton -import org.ooni.probe.ui.shared.NotificationMessages import org.ooni.probe.ui.shared.TopBar import org.ooni.probe.ui.shared.VerticalScrollbar @@ -199,11 +198,9 @@ fun AddDescriptorScreen( } } ?: LoadingDescriptor() - NotificationMessages( - message = state.messages, - onMessageDisplayed = { - onEvent(AddDescriptorViewModel.Event.MessageDisplayed(it)) - }, + AddDescriptorMessage( + message = state.messages.firstOrNull(), + onMessageDisplayed = { onEvent(AddDescriptorViewModel.Event.MessageDisplayed(it)) }, ) } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorUrlDialog.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorUrlDialog.kt new file mode 100644 index 000000000..a8f5a26ca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorUrlDialog.kt @@ -0,0 +1,100 @@ +package org.ooni.probe.ui.descriptor.add + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import ooniprobe.composeapp.generated.resources.AddDescriptor_EnterURL +import ooniprobe.composeapp.generated.resources.AddDescriptor_Title +import ooniprobe.composeapp.generated.resources.AddDescriptor_URLInvalid +import ooniprobe.composeapp.generated.resources.Common_Next +import ooniprobe.composeapp.generated.resources.Modal_Cancel +import ooniprobe.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun AddDescriptorUrlDialog( + state: AddDescriptorUrlViewModel.State, + onEvent: (AddDescriptorUrlViewModel.Event) -> Unit, +) { + Surface(shape = MaterialTheme.shapes.medium) { + Column( + Modifier.padding(all = 16.dp).fillMaxWidth(), + ) { + Text( + stringResource(Res.string.AddDescriptor_Title), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), + ) + Text( + stringResource(Res.string.AddDescriptor_EnterURL), + modifier = Modifier.padding(bottom = 16.dp), + ) + + OutlinedTextField( + value = state.input, + onValueChange = { onEvent(AddDescriptorUrlViewModel.Event.InputChanged(it)) }, + placeholder = { Text(AddDescriptorUrlViewModel.RUN_LINK_PREFIX + "…") }, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Uri, + ), + isError = state.isInvalid, + modifier = Modifier.fillMaxWidth(), + ) + if (state.isInvalid) { + Text( + text = stringResource(Res.string.AddDescriptor_URLInvalid), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 4.dp), + ) + } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + ) { + TextButton(onClick = { onEvent(AddDescriptorUrlViewModel.Event.CloseClicked) }) { + Text(stringResource(Res.string.Modal_Cancel)) + } + Button( + onClick = { onEvent(AddDescriptorUrlViewModel.Event.NextClicked) }, + enabled = !state.isInvalid, + ) { + Text(stringResource(Res.string.Common_Next)) + } + } + } + } +} + +@Composable +@Preview +fun AddDescriptorUrlDialogPreview() { + AddDescriptorUrlDialog( + state = AddDescriptorUrlViewModel.State(), + onEvent = {}, + ) +} + +@Composable +@Preview +fun AddDescriptorUrlDialogInvalidPreview() { + AddDescriptorUrlDialog( + state = AddDescriptorUrlViewModel.State(input = "invalid", isInvalid = true), + onEvent = {}, + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorUrlViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorUrlViewModel.kt new file mode 100644 index 000000000..8a7a7ad6e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorUrlViewModel.kt @@ -0,0 +1,84 @@ +package org.ooni.probe.ui.descriptor.add + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import org.ooni.probe.config.OrganizationConfig +import org.ooni.probe.data.models.InstalledTestDescriptorModel + +class AddDescriptorUrlViewModel( + onClose: () -> Unit, + goToAddDescriptor: (InstalledTestDescriptorModel.Id) -> Unit, +) : ViewModel() { + private val events = MutableSharedFlow(extraBufferCapacity = 1) + + private val _state = MutableStateFlow(State()) + val state = _state.asStateFlow() + + init { + events + .filterIsInstance() + .onEach { onClose() } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { event -> + _state.update { + it.copy( + input = event.value, + isInvalid = false, + ) + } + }.launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { + val input = _state.value.input + if (!input.isValidInput()) { + _state.update { it.copy(isInvalid = true) } + return@onEach + } + + goToAddDescriptor( + InstalledTestDescriptorModel.Id( + input.removePrefix(RUN_LINK_PREFIX), + ), + ) + }.launchIn(viewModelScope) + } + + fun onEvent(event: Event) { + events.tryEmit(event) + } + + private fun String.isValidInput() = + toLongOrNull() != null || + (startsWith(RUN_LINK_PREFIX) && length > RUN_LINK_PREFIX.length) + + data class State( + val input: String = "", + val isInvalid: Boolean = false, + ) + + sealed interface Event { + data object CloseClicked : Event + + data class InputChanged( + val value: String, + ) : Event + + data object NextClicked : Event + } + + companion object { + val RUN_LINK_PREFIX = "${OrganizationConfig.ooniRunDashboardUrl}/v2/" + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorViewModel.kt index 36b87ae0d..f7ca3285f 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/add/AddDescriptorViewModel.kt @@ -52,9 +52,9 @@ class AddDescriptorViewModel( }.orEmpty(), ) }.onFailure { error -> - Logger.e("Failed to fetch descriptor", error) + Logger.i("Failed to fetch descriptor", error) _state.update { - it.copy(messages = it.messages + SnackBarMessage.AddDescriptorFailed) + it.copy(messages = it.messages + Message.FailedToFetch) } onBack() } @@ -93,19 +93,15 @@ class AddDescriptorViewModel( events .filterIsInstance() - .onEach { event -> - _state.update { - it.copy(messages = it.messages + SnackBarMessage.AddDescriptorCancel) - } - onBack() - }.launchIn(viewModelScope) + .onEach { onBack() } + .launchIn(viewModelScope) events .filterIsInstance() .onEach { event -> installDescriptorAndSavePreferences() _state.update { - it.copy(messages = it.messages + SnackBarMessage.AddDescriptorSuccess) + it.copy(messages = it.messages + Message.AddDescriptorSuccess) } onBack() }.launchIn(viewModelScope) @@ -119,7 +115,7 @@ class AddDescriptorViewModel( RunSpecification.buildForDescriptor(installedDescriptor.toDescriptor()), ) _state.update { - it.copy(messages = it.messages + SnackBarMessage.AddDescriptorSuccess) + it.copy(messages = it.messages + Message.AddDescriptorSuccess) } onBack() }.launchIn(viewModelScope) @@ -160,7 +156,7 @@ class AddDescriptorViewModel( data class State( val descriptor: InstalledTestDescriptorModel? = null, val selectableItems: List> = emptyList(), - val messages: List = emptyList(), + val messages: List = emptyList(), val autoUpdate: Boolean = true, ) { fun allTestsSelected(): ToggleableState { @@ -194,15 +190,12 @@ class AddDescriptorViewModel( ) : Event data class MessageDisplayed( - val message: SnackBarMessage, + val message: Message, ) : Event } - sealed interface SnackBarMessage { - data object AddDescriptorFailed : SnackBarMessage - - data object AddDescriptorCancel : SnackBarMessage - - data object AddDescriptorSuccess : SnackBarMessage + enum class Message { + FailedToFetch, + AddDescriptorSuccess, } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt new file mode 100644 index 000000000..d838a5d77 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsScreen.kt @@ -0,0 +1,308 @@ +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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import ooniprobe.composeapp.generated.resources.AddDescriptor_Title +import ooniprobe.composeapp.generated.resources.Common_Clear +import ooniprobe.composeapp.generated.resources.Common_Collapse +import ooniprobe.composeapp.generated.resources.Common_Expand +import ooniprobe.composeapp.generated.resources.Common_Search +import ooniprobe.composeapp.generated.resources.DescriptorUpdate_CheckUpdates +import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.Tests_Search +import ooniprobe.composeapp.generated.resources.Tests_Title +import ooniprobe.composeapp.generated.resources.ic_keyboard_arrow_down +import ooniprobe.composeapp.generated.resources.ic_keyboard_arrow_up +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview +import org.ooni.probe.config.OrganizationConfig +import org.ooni.probe.data.models.Descriptor +import org.ooni.probe.data.models.DescriptorType +import org.ooni.probe.data.models.DescriptorUpdateOperationState +import org.ooni.probe.ui.shared.ColorDefaults +import org.ooni.probe.ui.shared.NavigationBackButton +import org.ooni.probe.ui.shared.TopBar +import org.ooni.probe.ui.shared.UpdateProgressStatus +import org.ooni.probe.ui.shared.VerticalScrollbar +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()) { + if (state.isFiltering) { + Surface( + color = ColorDefaults.topAppBar().containerColor, + contentColor = ColorDefaults.topAppBar().titleContentColor, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(WindowInsets.statusBars.asPaddingValues()) + .defaultMinSize(minHeight = TopAppBarDefaults.TopAppBarExpandedHeight), + ) { + NavigationBackButton(onClick = { onEvent(DescriptorsViewModel.Event.CloseFilterClicked) }) + + OutlinedTextField( + value = state.filterText.orEmpty(), + onValueChange = { + onEvent(DescriptorsViewModel.Event.FilterTextChanged(it)) + }, + placeholder = { Text(stringResource(Res.string.Tests_Search)) }, + trailingIcon = { + IconButton( + onClick = { + onEvent(DescriptorsViewModel.Event.FilterTextChanged("")) + }, + enabled = !state.filterText.isNullOrEmpty(), + ) { + Icon( + Icons.Default.Clear, + contentDescription = stringResource(Res.string.Common_Clear), + ) + } + }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + focusedTextColor = LocalContentColor.current, + unfocusedTextColor = LocalContentColor.current, + focusedPlaceholderColor = LocalContentColor.current.copy(alpha = 0.7f), + unfocusedPlaceholderColor = LocalContentColor.current.copy(alpha = 0.7f), + focusedTrailingIconColor = LocalContentColor.current, + unfocusedTrailingIconColor = LocalContentColor.current, + cursorColor = LocalContentColor.current, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + } + } else { + TopBar( + title = { Text(stringResource(Res.string.Tests_Title)) }, + actions = { + if (OrganizationConfig.canInstallDescriptors) { + IconButton(onClick = { onEvent(DescriptorsViewModel.Event.FilterClicked) }) { + Icon( + Icons.Default.Search, + contentDescription = stringResource(Res.string.Common_Search), + ) + } + IconButton(onClick = { onEvent(DescriptorsViewModel.Event.AddClicked) }) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(Res.string.AddDescriptor_Title), + ) + } + } + }, + ) + } + + Box { + val lazyListState = rememberLazyListState() + LazyColumn( + modifier = Modifier.testTag("Descriptors-List"), + contentPadding = PaddingValues(vertical = 16.dp), + state = lazyListState, + ) { + val allSectionsHaveValues = state.sections.all { it.descriptors.any() } + state.sections.forEach { (type, descriptors, isCollapsed) -> + if (!state.isFiltering && allSectionsHaveValues && descriptors.isNotEmpty()) { + item(type) { + TestDescriptorSectionTitle( + type = type, + isCollapsed = isCollapsed, + state = state, + onEvent = onEvent, + ) + } + } + if (isCollapsed && !state.isFiltering) return@forEach + items(descriptors, key = { it.key }) { descriptor -> + if (descriptor.matches(state.filterText)) { + 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, + ) + } + + BackHandler(enabled = state.isFiltering) { + onEvent(DescriptorsViewModel.Event.CloseFilterClicked) + } +} + +@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, + ) + } +} + +@Composable +private fun Descriptor.matches(filter: String?) = + filter == null || + title().contains(filter, ignoreCase = true) || + shortDescription()?.contains(filter, ignoreCase = true) == true + +@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..46edaeb48 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptors/DescriptorsViewModel.kt @@ -0,0 +1,209 @@ +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, + goToAddDescriptorUrl: () -> 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) + + events + .filterIsInstance() + .onEach { goToAddDescriptorUrl() } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { _state.update { it.copy(filterText = "") } } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { event -> _state.update { it.copy(filterText = event.text) } } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { _state.update { it.copy(filterText = null) } } + .launchIn(viewModelScope) + } + + fun onEvent(event: Event) { + 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 filterText: String? = null, + ) { + val isRefreshing: Boolean + get() = descriptorsUpdateOperationState == DescriptorUpdateOperationState.FetchingUpdates + + val isRefreshEnabled: Boolean + get() = sections + .firstOrNull { it.type == DescriptorType.Installed } + ?.descriptors + ?.any() == true + + val isFiltering get() = filterText != null + } + + 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 object AddClicked : Event + + data object FilterClicked : Event + + data class FilterTextChanged( + val text: String, + ) : Event + + data object CloseFilterClicked : 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/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/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..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 @@ -25,10 +25,12 @@ 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 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,8 +50,8 @@ fun BottomNavigationBar( modifier = customMinHeightModifier, ) { MAIN_NAVIGATION_SCREENS.forEach { screen -> - val screen = screen as Screen val isCurrentScreen = entry?.destination?.hasRoute(screen::class) == true + val screen = screen as Screen NavigationBarItem( icon = { NavigationBadgeBox( @@ -97,10 +99,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,7 +115,8 @@ private val Screen.titleRes get() = when (this) { Screen.Dashboard -> Res.string.Dashboard_Tab_Label - Screen.Results -> Res.string.TestResults_Overview_Tab_Label + Screen.Descriptors -> Res.string.Tests_Title + Screen.Results -> Res.string.TestResults Screen.Settings -> Res.string.Settings_Title else -> throw IllegalArgumentException("Only main screens allowed in bottom navigation") } @@ -120,6 +125,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..44ca9cb43 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Navigation.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Navigation.kt @@ -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,12 +23,16 @@ 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 import org.ooni.probe.ui.descriptor.add.AddDescriptorScreen +import org.ooni.probe.ui.descriptor.add.AddDescriptorUrlDialog import org.ooni.probe.ui.descriptor.review.ReviewUpdatesScreen import org.ooni.probe.ui.descriptor.websites.DescriptorWebsitesViewModel +import org.ooni.probe.ui.descriptors.DescriptorsScreen import org.ooni.probe.ui.log.LogScreen import org.ooni.probe.ui.measurement.MeasurementRawScreen import org.ooni.probe.ui.measurement.MeasurementScreen @@ -80,16 +85,34 @@ 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), + ) + }, + goToArticles = { navController.safeNavigate(Screen.Articles) }, + goToArticle = { navController.safeNavigate(Screen.Article(it.value)) }, + ) + } + val state by viewModel.state.collectAsState() + DashboardScreen(state, viewModel::onEvent) + } + + composable { + val viewModel = viewModel { + dependencies.descriptorsViewModel( goToDescriptor = { descriptorKey -> navController.safeNavigate(Screen.Descriptor(descriptorKey)) }, goToReviewDescriptorUpdates = { list -> navController.safeNavigate(Screen.ReviewUpdates(list?.map { it.value })) }, + goToAddDescriptorUrl = { navController.safeNavigate(Screen.AddDescriptorUrl) }, ) } val state by viewModel.state.collectAsState() - DashboardScreen(state, viewModel::onEvent) + DescriptorsScreen(state, viewModel::onEvent) } composable { @@ -267,6 +290,17 @@ fun Navigation( AddDescriptorScreen(state, viewModel::onEvent) } + dialog { entry -> + val viewModel = viewModel { + dependencies.addDescriptorUrlViewModel( + onClose = { navController.goBack() }, + goToAddDescriptor = { navController.safeNavigate(Screen.AddDescriptor(it.value)) }, + ) + } + val state by viewModel.state.collectAsState() + AddDescriptorUrlDialog(state, viewModel::onEvent) + } + composable { val viewModel = viewModel { dependencies.runningViewModel( @@ -363,6 +397,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 f9fb1fc94..22a1636e6 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Screen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Screen.kt @@ -4,58 +4,89 @@ 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 Results : Screen + @Serializable + data object Descriptors : Screen - @Serializable data object Settings : Screen + @Serializable + data object Results : Screen - @Serializable data class Result( + @Serializable + data object Settings : Screen + + @Serializable + data class Result( val resultId: Long, ) : Screen - @Serializable data class AddDescriptor( - val runId: Long, + @Serializable + data class AddDescriptor( + val runId: String, ) : Screen - @Serializable data class Measurement( + @Serializable + data object AddDescriptorUrl : Screen + + @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/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 4efa89448..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 @@ -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 @@ -74,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_Tests 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 @@ -94,12 +92,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 @@ -121,7 +119,7 @@ fun ResultsScreen( if (!state.selectionEnabled) { TopBar( title = { - Text(stringResource(Res.string.TestResults_Overview_Title)) + Text(stringResource(Res.string.TestResults)) }, actions = { IconButton( @@ -363,10 +361,6 @@ fun ResultsScreen( }, ) } - - LaunchedEffect(Unit) { - onEvent(ResultsViewModel.Event.Start) - } } @Composable @@ -446,7 +440,7 @@ private fun Summary(summary: ResultsViewModel.Summary?) { horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - stringResource(Res.string.TestResults_Overview_Hero_Tests), + 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/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/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/NotificationMessages.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/NotificationMessages.kt deleted file mode 100644 index 476a634ae..000000000 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/NotificationMessages.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.ooni.probe.ui.shared - -import androidx.compose.material3.SnackbarResult -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import ooniprobe.composeapp.generated.resources.AddDescriptor_Toasts_Installed -import ooniprobe.composeapp.generated.resources.LoadingScreen_Runv2_Canceled -import ooniprobe.composeapp.generated.resources.LoadingScreen_Runv2_Failure -import ooniprobe.composeapp.generated.resources.Res -import org.jetbrains.compose.resources.getString -import org.ooni.probe.LocalSnackbarHostState -import org.ooni.probe.ui.descriptor.add.AddDescriptorViewModel - -@Composable -fun NotificationMessages( - message: List, - onMessageDisplayed: (AddDescriptorViewModel.SnackBarMessage) -> Unit = { }, -) { - val snackbarHostState = LocalSnackbarHostState.current ?: return - LaunchedEffect(message) { - val errorMessage = when (message.firstOrNull()) { - AddDescriptorViewModel.SnackBarMessage.AddDescriptorSuccess -> getString(Res.string.AddDescriptor_Toasts_Installed) - AddDescriptorViewModel.SnackBarMessage.AddDescriptorFailed -> getString(Res.string.LoadingScreen_Runv2_Failure) - AddDescriptorViewModel.SnackBarMessage.AddDescriptorCancel -> getString(Res.string.LoadingScreen_Runv2_Canceled) - else -> "" - } - val error = message.firstOrNull() ?: return@LaunchedEffect - val result = snackbarHostState.showSnackbar(errorMessage) - if (result == SnackbarResult.Dismissed) { - onMessageDisplayed(error) - } - } -} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/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/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/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/kotlin/org/ooni/probe/ui/upload/UploadMeasurementsDialog.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/upload/UploadMeasurementsDialog.kt index fca91f520..cdc264b29 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/upload/UploadMeasurementsDialog.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/upload/UploadMeasurementsDialog.kt @@ -33,7 +33,7 @@ fun UploadMeasurementsDialog( state: UploadMissingMeasurements.State, onEvent: (UploadMeasurementsViewModel.Event) -> Unit, ) { - Surface { + Surface(shape = MaterialTheme.shapes.medium) { Column( modifier = Modifier .padding(16.dp) diff --git a/composeApp/src/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/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/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 <> ''; 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/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..6374c7f56 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/GetFindingsTest.kt @@ -0,0 +1,127 @@ +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 + +class GetFindingsTest { + @Test + fun invoke() = + runTest { + val subject = GetFindings( + httpDo = { _, _, _ -> Success(API_RESPONSE) }, + json = Dependencies.buildJson(), + ) + + 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 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() + } +} 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/articles/RefreshArticlesTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/RefreshArticlesTest.kt new file mode 100644 index 000000000..e270b9969 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/domain/articles/RefreshArticlesTest.kt @@ -0,0 +1,94 @@ +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 refreshSoonerIfSkip() = + runTest { + var sourceCalled = false + val subject = RefreshArticles( + sources = listOf( + RefreshArticles.Source { + sourceCalled = true + Failure(Exception()) + }, + ), + refreshArticlesInDatabase = { }, + getPreference = { flowOf(Clock.System.now().epochSeconds) }, + setPreference = { _, _ -> }, + ) + + subject(skipIntervalCheck = true) + + assertTrue(sourceCalled) + } + + @Test + fun success() = + runTest { + 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) + } +} 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/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/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/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) } 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 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/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 c0e683a52..e77e53e4c 100755 Binary files a/composeApp/src/desktopMain/resources/macos/libupdatebridge.dylib and b/composeApp/src/desktopMain/resources/macos/libupdatebridge.dylib differ 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..fb719dac6 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,6 @@ object OrganizationConfig : OrganizationConfigInterface { ) override val hasWebsitesDescriptor = false override val donateUrl = null + override val hasOoniNews = false + override val canInstallDescriptors = false } 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") 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 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..b4bd83706 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,6 @@ object OrganizationConfig : OrganizationConfigInterface { ) override val hasWebsitesDescriptor = true override val donateUrl = "https://ooni.org/donate" + override val hasOoniNews = true + override val canInstallDescriptors = true } diff --git a/composeApp/src/ooniMain/kotlin/org/ooni/probe/ui/Colors.kt b/composeApp/src/ooniMain/kotlin/org/ooni/probe/ui/Colors.kt index 54dd593e9..b7da1440b 100644 --- a/composeApp/src/ooniMain/kotlin/org/ooni/probe/ui/Colors.kt +++ b/composeApp/src/ooniMain/kotlin/org/ooni/probe/ui/Colors.kt @@ -23,7 +23,7 @@ val onBackgroundLight = Color(0xFF000000) val surfaceLight = Color(0xFFF7F9FF) val onSurfaceLight = Color(0xFF000000) val surfaceVariantLight = Color(0xFFF0F0F0) -val onSurfaceVariantLight = Color(0xFF000000) +val onSurfaceVariantLight = Color(0xFF777777) val outlineLight = Color(0xFF74777F) val outlineVariantLight = Color(0xFFC4C6D0) val scrimLight = Color(0xFF000000) @@ -59,7 +59,7 @@ val onBackgroundDark = Color(0xFFE0E2E8) val surfaceDark = Color(0xFF101418) val onSurfaceDark = Color(0xFFE0E2E8) val surfaceVariantDark = Color(0xFF333333) -val onSurfaceVariantDark = Color(0xFFE0E2E8) +val onSurfaceVariantDark = Color(0xFFCCCCCC) val outlineDark = Color(0xFF8E9099) val outlineVariantDark = Color(0xFF44474E) val scrimDark = Color(0xFF000000) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 19179a41a..e60ad12e3 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.3" } kotlin-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.7.1" } # Java @@ -111,7 +112,8 @@ desktop-oonimkall = { module = "org.ooni:oonimkall", version = "3.27.0-desktop" [bundles] kotlin = [ - "kotlin-serialization", + "kotlin-serialization-json", + "kotlin-serialization-xml", "kotlin-datetime", ] ui = [