diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 4f4025bb..99a03f09 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -104,7 +104,7 @@ kotlin { all { languageSettings { optIn("kotlin.ExperimentalStdlibApi") - // optIn("kotlinx.cinterop.ExperimentalForeignApi") + optIn("kotlinx.cinterop.ExperimentalForeignApi") optIn("kotlinx.cinterop.BetaInteropApi") optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") optIn("androidx.compose.foundation.ExperimentalFoundationApi") diff --git a/composeApp/src/androidMain/kotlin/org/ooni/probe/AndroidApplication.kt b/composeApp/src/androidMain/kotlin/org/ooni/probe/AndroidApplication.kt index 44face69..f561e698 100644 --- a/composeApp/src/androidMain/kotlin/org/ooni/probe/AndroidApplication.kt +++ b/composeApp/src/androidMain/kotlin/org/ooni/probe/AndroidApplication.kt @@ -3,6 +3,9 @@ package org.ooni.probe import android.app.Application import android.net.ConnectivityManager import android.os.Build +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.SharedPreferencesMigration +import androidx.datastore.preferences.core.Preferences import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver import org.ooni.engine.AndroidNetworkTypeFinder @@ -26,6 +29,7 @@ class AndroidApplication : Application() { databaseDriverFactory = ::buildDatabaseDriver, networkTypeFinder = AndroidNetworkTypeFinder(getSystemService(ConnectivityManager::class.java)), + buildDataStore = ::buildDataStore, ) } @@ -41,4 +45,10 @@ class AndroidApplication : Application() { private fun buildDatabaseDriver(): SqlDriver = AndroidSqliteDriver(Database.Schema, this, "v2") private fun readAssetFile(path: String) = assets.open(path).bufferedReader().use { it.readText() } + + private fun buildDataStore(): DataStore = + Dependencies.getDataStore( + producePath = { this.filesDir.resolve(Dependencies.Companion.DATA_STORE_FILE_NAME).absolutePath }, + migrations = listOf(SharedPreferencesMigration(this, "${packageName}_preferences")), + ) } diff --git a/composeApp/src/androidUnitTest/kotlin/org/ooni/testing/CreatePreferenceDataStore.kt b/composeApp/src/androidUnitTest/kotlin/org/ooni/testing/CreatePreferenceDataStore.kt new file mode 100644 index 00000000..26c271e0 --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/org/ooni/testing/CreatePreferenceDataStore.kt @@ -0,0 +1,14 @@ +package org.ooni.testing + +import android.app.Application +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.test.core.app.ApplicationProvider +import org.ooni.probe.di.Dependencies + +internal actual fun createPreferenceDataStore(): DataStore { + val app = ApplicationProvider.getApplicationContext() + return Dependencies.getDataStore( + producePath = { app.filesDir.resolve("test" + Dependencies.Companion.DATA_STORE_FILE_NAME).absolutePath }, + ) +} diff --git a/composeApp/src/commonMain/composeResources/drawable/advanced.xml b/composeApp/src/commonMain/composeResources/drawable/advanced.xml new file mode 100644 index 00000000..1ab4df6a --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/advanced.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_aldr.xml b/composeApp/src/commonMain/composeResources/drawable/category_aldr.xml new file mode 100644 index 00000000..4adffcd8 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_aldr.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_anon.xml b/composeApp/src/commonMain/composeResources/drawable/category_anon.xml new file mode 100644 index 00000000..da39c354 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_anon.xml @@ -0,0 +1,35 @@ + + + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_comm.xml b/composeApp/src/commonMain/composeResources/drawable/category_comm.xml new file mode 100644 index 00000000..83be7c22 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_comm.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_comt.xml b/composeApp/src/commonMain/composeResources/drawable/category_comt.xml new file mode 100644 index 00000000..36114747 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_comt.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_ctrl.xml b/composeApp/src/commonMain/composeResources/drawable/category_ctrl.xml new file mode 100644 index 00000000..185d2c48 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_ctrl.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_cultr.xml b/composeApp/src/commonMain/composeResources/drawable/category_cultr.xml new file mode 100644 index 00000000..1c582a44 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_cultr.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_date.xml b/composeApp/src/commonMain/composeResources/drawable/category_date.xml new file mode 100644 index 00000000..b75aec46 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_date.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_econ.xml b/composeApp/src/commonMain/composeResources/drawable/category_econ.xml new file mode 100644 index 00000000..ce617743 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_econ.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_env.xml b/composeApp/src/commonMain/composeResources/drawable/category_env.xml new file mode 100644 index 00000000..eef29942 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_env.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_file.xml b/composeApp/src/commonMain/composeResources/drawable/category_file.xml new file mode 100644 index 00000000..ce7ca471 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_file.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_game.xml b/composeApp/src/commonMain/composeResources/drawable/category_game.xml new file mode 100644 index 00000000..03409645 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_game.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_gmb.xml b/composeApp/src/commonMain/composeResources/drawable/category_gmb.xml new file mode 100644 index 00000000..24402937 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_gmb.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_govt.xml b/composeApp/src/commonMain/composeResources/drawable/category_govt.xml new file mode 100644 index 00000000..7e99f99a --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_govt.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_grp.xml b/composeApp/src/commonMain/composeResources/drawable/category_grp.xml new file mode 100644 index 00000000..a3276245 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_grp.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_hack.xml b/composeApp/src/commonMain/composeResources/drawable/category_hack.xml new file mode 100644 index 00000000..1ce1c6a7 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_hack.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_hate.xml b/composeApp/src/commonMain/composeResources/drawable/category_hate.xml new file mode 100644 index 00000000..57c370c0 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_hate.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_host.xml b/composeApp/src/commonMain/composeResources/drawable/category_host.xml new file mode 100644 index 00000000..638b1e56 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_host.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_humr.xml b/composeApp/src/commonMain/composeResources/drawable/category_humr.xml new file mode 100644 index 00000000..3497c37a --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_humr.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_igo.xml b/composeApp/src/commonMain/composeResources/drawable/category_igo.xml new file mode 100644 index 00000000..b2bd3d9f --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_igo.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_lgbt.xml b/composeApp/src/commonMain/composeResources/drawable/category_lgbt.xml new file mode 100644 index 00000000..890ad7cc --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_lgbt.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_milx.xml b/composeApp/src/commonMain/composeResources/drawable/category_milx.xml new file mode 100644 index 00000000..18d41657 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_milx.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_misc.xml b/composeApp/src/commonMain/composeResources/drawable/category_misc.xml new file mode 100644 index 00000000..11d3dd82 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_misc.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_mmed.xml b/composeApp/src/commonMain/composeResources/drawable/category_mmed.xml new file mode 100644 index 00000000..078222f1 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_mmed.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_news.xml b/composeApp/src/commonMain/composeResources/drawable/category_news.xml new file mode 100644 index 00000000..8da1b492 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_news.xml @@ -0,0 +1,35 @@ + + + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_polr.xml b/composeApp/src/commonMain/composeResources/drawable/category_polr.xml new file mode 100644 index 00000000..ea78fddd --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_polr.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_porn.xml b/composeApp/src/commonMain/composeResources/drawable/category_porn.xml new file mode 100644 index 00000000..486cf705 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_porn.xml @@ -0,0 +1,35 @@ + + + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_prov.xml b/composeApp/src/commonMain/composeResources/drawable/category_prov.xml new file mode 100644 index 00000000..6a2063f7 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_prov.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_pubh.xml b/composeApp/src/commonMain/composeResources/drawable/category_pubh.xml new file mode 100644 index 00000000..978227ac --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_pubh.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_rel.xml b/composeApp/src/commonMain/composeResources/drawable/category_rel.xml new file mode 100644 index 00000000..fb82c260 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_rel.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_srch.xml b/composeApp/src/commonMain/composeResources/drawable/category_srch.xml new file mode 100644 index 00000000..3b995649 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_srch.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/category_xed.xml b/composeApp/src/commonMain/composeResources/drawable/category_xed.xml new file mode 100644 index 00000000..95f53650 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/category_xed.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/notifications.xml b/composeApp/src/commonMain/composeResources/drawable/notifications.xml new file mode 100644 index 00000000..296b925d --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/notifications.xml @@ -0,0 +1,13 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/outline_info.xml b/composeApp/src/commonMain/composeResources/drawable/outline_info.xml new file mode 100644 index 00000000..dd51cba7 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/outline_info.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/privacy.xml b/composeApp/src/commonMain/composeResources/drawable/privacy.xml new file mode 100644 index 00000000..c756a2b8 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/privacy.xml @@ -0,0 +1,13 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/proxy.xml b/composeApp/src/commonMain/composeResources/drawable/proxy.xml new file mode 100644 index 00000000..01f9f771 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/proxy.xml @@ -0,0 +1,13 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/send_email.xml b/composeApp/src/commonMain/composeResources/drawable/send_email.xml new file mode 100644 index 00000000..cd2d858d --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/send_email.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index 0e77e0f4..0436d649 100644 --- a/composeApp/src/commonMain/composeResources/values/strings-common.xml +++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml @@ -11,4 +11,101 @@ Test Results Unknown N/A + + Notifications + Enabled + Interested in running OONI Probe tests during emergent censorship events? Enable notifications to receive a message when we hear of internet censorship near you. + + Test options + + Run tests automatically + Number of automated tests: %1$s. + Last automated test: %1$s. + Only on WiFi + Only while charging + By enabling automatic testing, OONI Probe tests will run automatically multiple times per day. Your test results will automatically get published on OONI Explorer: https://explorer.ooni.org/ \n\nImportant: If you have a VPN enabled, OONI Probe will not run tests automatically. Please turn off your VPN for automated OONI Probe testing. Learn more: https://ooni.org/support/faq/#can-i-run-ooni-probe-over-a-vpn + + Limit test duration + Test duration + Website categories to test + %1$s categories enabled + + Privacy + + Automatically Publish Results + Send crash reports + + OONI backend proxy + Advanced + Send email to support + + Debug logs + See recent logs + Language Setting + Storage usage + Warn when VPN is in use + + Drugs & Alcohol + Religion + Pornography + Provocative Attire + Political Criticism + Human Rights Issues + Environment + Terrorism and Militants + Hate Speech + News Media + Sex Education + Public Health + Gambling + Circumvention tools + Online Dating + Social Networking + LGBTQ+ + File-sharing + Hacking Tools + Communication Tools + Media sharing + Hosting and Blogging + Search Engines + Gaming + Culture + Economics + Government + E-commerce + Control content + Intergovernmental Orgs. + Miscellaneous content + Use and sale of drugs and alcohol + Religious issues, both supportive and critical + Hard-core and soft-core pornography + Provocative attire and portrayal of women wearing minimal clothing + Critical political viewpoints + Human rights issues + Discussions on environmental issues + Terrorism, violent militant or separatist movements + Disparaging of particular groups based on race, sex, sexuality or other characteristics + Major news websites, regional news outlets and independent media + Sexual health issues including contraception, STD\'s, rape prevention and abortion + Public health issues, such as COVID-19, HIV/AIDS, Ebola + Online gambling and betting + Anonymization, censorship circumvention and encryption + Online dating sites + Online social networking tools and platforms + LGBTQ+ communities discussing related issues (excluding pornography) + File sharing including cloud-based file storage, torrents and P2P + Computer security tools and news + Individual and group communication tools including VoIP, messaging and webmail + Video, audio and photo sharing + Web hosting, blogging and other online publishing + Search engines and portals + Online games and gaming platforms (excluding gambling sites) + Entertainment including history, literature, music, film, satire and humour + General economic development and poverty + Government-run websites, including military + Commercial services and products + Benign or innocuous content used for control + Intergovernmental organizations including The United Nations + Sites that haven\'t been categorized yet + diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/PreferenceModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/PreferenceModel.kt new file mode 100644 index 00000000..93de9e00 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/PreferenceModel.kt @@ -0,0 +1,641 @@ +package org.ooni.probe.data.models + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import ooniprobe.composeapp.generated.resources.CategoryCode_ALDR_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_ALDR_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_ANON_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_ANON_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_COMM_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_COMM_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_COMT_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_COMT_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_CTRL_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_CTRL_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_CULTR_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_CULTR_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_DATE_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_DATE_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_ECON_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_ECON_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_ENV_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_ENV_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_FILE_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_FILE_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_GAME_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_GAME_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_GMB_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_GMB_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_GOVT_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_GOVT_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_GRP_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_GRP_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_HACK_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_HACK_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_HATE_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_HATE_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_HOST_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_HOST_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_HUMR_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_HUMR_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_IGO_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_IGO_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_LGBT_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_LGBT_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_MILX_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_MILX_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_MMED_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_MMED_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_NEWS_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_NEWS_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_POLR_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_POLR_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_PORN_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_PORN_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_PROV_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_PROV_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_PUBH_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_PUBH_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_REL_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_REL_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_SRCH_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_SRCH_Name +import ooniprobe.composeapp.generated.resources.CategoryCode_XED_Description +import ooniprobe.composeapp.generated.resources.CategoryCode_XED_Name +import ooniprobe.composeapp.generated.resources.Modal_EnableNotifications_Paragraph +import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.Settings_About_Label +import ooniprobe.composeapp.generated.resources.Settings_Advanced_DebugLogs +import ooniprobe.composeapp.generated.resources.Settings_Advanced_Label +import ooniprobe.composeapp.generated.resources.Settings_Advanced_LanguageSettings_Title +import ooniprobe.composeapp.generated.resources.Settings_Advanced_RecentLogs +import ooniprobe.composeapp.generated.resources.Settings_AutomatedTesting_RunAutomatically +import ooniprobe.composeapp.generated.resources.Settings_AutomatedTesting_RunAutomatically_ChargingOnly +import ooniprobe.composeapp.generated.resources.Settings_AutomatedTesting_RunAutomatically_Footer +import ooniprobe.composeapp.generated.resources.Settings_AutomatedTesting_RunAutomatically_WiFiOnly +import ooniprobe.composeapp.generated.resources.Settings_Notifications_Enabled +import ooniprobe.composeapp.generated.resources.Settings_Notifications_Label +import ooniprobe.composeapp.generated.resources.Settings_Privacy_Label +import ooniprobe.composeapp.generated.resources.Settings_Privacy_SendCrashReports +import ooniprobe.composeapp.generated.resources.Settings_Proxy_Label +import ooniprobe.composeapp.generated.resources.Settings_SendEmail_Label +import ooniprobe.composeapp.generated.resources.Settings_Sharing_UploadResults +import ooniprobe.composeapp.generated.resources.Settings_Storage_Label +import ooniprobe.composeapp.generated.resources.Settings_TestOptions_Label +import ooniprobe.composeapp.generated.resources.Settings_WarmVPNInUse_Label +import ooniprobe.composeapp.generated.resources.Settings_Websites_Categories_Description +import ooniprobe.composeapp.generated.resources.Settings_Websites_Categories_Label +import ooniprobe.composeapp.generated.resources.Settings_Websites_MaxRuntime +import ooniprobe.composeapp.generated.resources.Settings_Websites_MaxRuntimeEnabled +import ooniprobe.composeapp.generated.resources.advanced +import ooniprobe.composeapp.generated.resources.category_aldr +import ooniprobe.composeapp.generated.resources.category_anon +import ooniprobe.composeapp.generated.resources.category_comm +import ooniprobe.composeapp.generated.resources.category_comt +import ooniprobe.composeapp.generated.resources.category_ctrl +import ooniprobe.composeapp.generated.resources.category_cultr +import ooniprobe.composeapp.generated.resources.category_date +import ooniprobe.composeapp.generated.resources.category_econ +import ooniprobe.composeapp.generated.resources.category_env +import ooniprobe.composeapp.generated.resources.category_file +import ooniprobe.composeapp.generated.resources.category_game +import ooniprobe.composeapp.generated.resources.category_gmb +import ooniprobe.composeapp.generated.resources.category_govt +import ooniprobe.composeapp.generated.resources.category_grp +import ooniprobe.composeapp.generated.resources.category_hack +import ooniprobe.composeapp.generated.resources.category_hate +import ooniprobe.composeapp.generated.resources.category_host +import ooniprobe.composeapp.generated.resources.category_humr +import ooniprobe.composeapp.generated.resources.category_igo +import ooniprobe.composeapp.generated.resources.category_lgbt +import ooniprobe.composeapp.generated.resources.category_milx +import ooniprobe.composeapp.generated.resources.category_mmed +import ooniprobe.composeapp.generated.resources.category_news +import ooniprobe.composeapp.generated.resources.category_polr +import ooniprobe.composeapp.generated.resources.category_porn +import ooniprobe.composeapp.generated.resources.category_prov +import ooniprobe.composeapp.generated.resources.category_pubh +import ooniprobe.composeapp.generated.resources.category_rel +import ooniprobe.composeapp.generated.resources.category_srch +import ooniprobe.composeapp.generated.resources.category_xed +import ooniprobe.composeapp.generated.resources.ic_settings +import ooniprobe.composeapp.generated.resources.notifications +import ooniprobe.composeapp.generated.resources.outline_info +import ooniprobe.composeapp.generated.resources.privacy +import ooniprobe.composeapp.generated.resources.proxy +import ooniprobe.composeapp.generated.resources.send_email +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.ooni.probe.ui.settings.SettingsViewModel +import org.ooni.probe.ui.settings.category.SettingsDescription + +open class PreferenceItem( + open val title: StringResource, + open val icon: DrawableResource? = null, + open val type: PreferenceItemType, + open val key: SettingsKey, + open val supportingContent: + @Composable() + (() -> Unit)? = null, +) + +data class SettingsItem( + override val icon: DrawableResource? = null, + override val title: StringResource, + override val type: PreferenceItemType, + override val key: SettingsKey, + val children: List? = emptyList(), + override val supportingContent: + @Composable() + (() -> Unit)? = null, +) : PreferenceItem(title = title, icon = icon, supportingContent = supportingContent, type = type, key = key) + +data class SettingsCategoryItem( + override val icon: DrawableResource? = null, + override val title: StringResource, + val route: PreferenceCategoryKey, + val settings: List? = emptyList(), + override val supportingContent: + @Composable (() -> Unit)? = null, + val footerContent: + @Composable (() -> Unit)? = null, +) : PreferenceItem( + title = title, + icon = icon, + supportingContent = supportingContent, + type = PreferenceItemType.ROUTE, + key = SettingsKey.ROUTE, + ) { + fun routeToSettingsCategory() = SettingsViewModel.Event.SettingsCategoryClick(route) + + companion object { + private val seeRecentLogsCategory = + SettingsCategoryItem( + title = Res.string.Settings_Advanced_RecentLogs, + route = PreferenceCategoryKey.SEE_RECENT_LOGS, + ) + private val webCategory = + SettingsCategoryItem( + title = Res.string.Settings_Websites_Categories_Label, + route = PreferenceCategoryKey.WEBSITES_CATEGORIES, + supportingContent = { + // TODO(norbel): add enabled categories + Text(stringResource(Res.string.Settings_Websites_Categories_Description)) + }, + settings = + listOf( + SettingsItem( + icon = Res.drawable.category_anon, + title = Res.string.CategoryCode_ANON_Name, + supportingContent = { + Text(stringResource(Res.string.CategoryCode_ANON_Description)) + }, + key = SettingsKey.ANON, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_comt, + title = Res.string.CategoryCode_COMT_Name, + supportingContent = { + Text(stringResource(Res.string.CategoryCode_COMT_Description)) + }, + key = SettingsKey.COMT, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_ctrl, + title = Res.string.CategoryCode_CTRL_Name, + supportingContent = { + Text(stringResource(Res.string.CategoryCode_CTRL_Description)) + }, + key = SettingsKey.CTRL, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_cultr, + title = Res.string.CategoryCode_CULTR_Name, + supportingContent = { + Text(stringResource(Res.string.CategoryCode_CULTR_Description)) + }, + key = SettingsKey.CULTR, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_aldr, + title = Res.string.CategoryCode_ALDR_Name, + supportingContent = { + Text(stringResource(Res.string.CategoryCode_ALDR_Description)) + }, + key = SettingsKey.ALDR, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_comm, + title = Res.string.CategoryCode_COMM_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_COMM_Description)) }, + key = SettingsKey.COMM, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_econ, + title = Res.string.CategoryCode_ECON_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_ECON_Description)) }, + key = SettingsKey.ECON, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_env, + title = Res.string.CategoryCode_ENV_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_ENV_Description)) }, + key = SettingsKey.ENV, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_file, + title = Res.string.CategoryCode_FILE_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_FILE_Description)) }, + key = SettingsKey.FILE, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_gmb, + title = Res.string.CategoryCode_GMB_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_GMB_Description)) }, + key = SettingsKey.GMB, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_game, + title = Res.string.CategoryCode_GAME_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_GAME_Description)) }, + key = SettingsKey.GAME, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_govt, + title = Res.string.CategoryCode_GOVT_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_GOVT_Description)) }, + key = SettingsKey.GOVT, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_hack, + title = Res.string.CategoryCode_HACK_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_HACK_Description)) }, + key = SettingsKey.HACK, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_hate, + title = Res.string.CategoryCode_HATE_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_HATE_Description)) }, + key = SettingsKey.HATE, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_host, + title = Res.string.CategoryCode_HOST_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_HOST_Description)) }, + key = SettingsKey.HOST, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_humr, + title = Res.string.CategoryCode_HUMR_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_HUMR_Description)) }, + key = SettingsKey.HUMR, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_igo, + title = Res.string.CategoryCode_IGO_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_IGO_Description)) }, + key = SettingsKey.IGO, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_lgbt, + title = Res.string.CategoryCode_LGBT_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_LGBT_Description)) }, + key = SettingsKey.LGBT, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_mmed, + title = Res.string.CategoryCode_MMED_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_MMED_Description)) }, + key = SettingsKey.MMED, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_news, + title = Res.string.CategoryCode_NEWS_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_NEWS_Description)) }, + key = SettingsKey.NEWS, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_date, + title = Res.string.CategoryCode_DATE_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_DATE_Description)) }, + key = SettingsKey.DATE, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_polr, + title = Res.string.CategoryCode_POLR_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_POLR_Description)) }, + key = SettingsKey.POLR, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_porn, + title = Res.string.CategoryCode_PORN_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_PORN_Description)) }, + key = SettingsKey.PORN, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_prov, + title = Res.string.CategoryCode_PROV_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_PROV_Description)) }, + key = SettingsKey.PROV, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_pubh, + title = Res.string.CategoryCode_PUBH_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_PUBH_Description)) }, + key = SettingsKey.PUBH, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_rel, + title = Res.string.CategoryCode_REL_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_REL_Description)) }, + key = SettingsKey.REL, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_srch, + title = Res.string.CategoryCode_SRCH_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_SRCH_Description)) }, + key = SettingsKey.SRCH, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_xed, + title = Res.string.CategoryCode_XED_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_XED_Description)) }, + key = SettingsKey.XED, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_grp, + title = Res.string.CategoryCode_GRP_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_GRP_Description)) }, + key = SettingsKey.GRP, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + icon = Res.drawable.category_milx, + title = Res.string.CategoryCode_MILX_Name, + supportingContent = { Text(stringResource(Res.string.CategoryCode_MILX_Description)) }, + key = SettingsKey.MILX, + type = PreferenceItemType.SWITCH, + ), + ), + ) + + fun getSettingsItems() = + listOf( + SettingsCategoryItem( + icon = Res.drawable.notifications, + title = Res.string.Settings_Notifications_Label, + route = PreferenceCategoryKey.NOTIFICATIONS, + settings = + listOf( + SettingsItem( + title = Res.string.Settings_Notifications_Enabled, + key = SettingsKey.NOTIFICATIONS_ENABLED, + type = PreferenceItemType.SWITCH, + ), + ), + footerContent = { + SettingsDescription( + Res.string.Modal_EnableNotifications_Paragraph, + ) + }, + ), + SettingsCategoryItem( + icon = Res.drawable.ic_settings, + title = Res.string.Settings_TestOptions_Label, + route = PreferenceCategoryKey.TEST_OPTIONS, + settings = + listOf( + SettingsItem( + title = Res.string.Settings_AutomatedTesting_RunAutomatically, + key = SettingsKey.AUTOMATED_TESTING_ENABLED, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + title = Res.string.Settings_AutomatedTesting_RunAutomatically_WiFiOnly, + key = SettingsKey.AUTOMATED_TESTING_WIFIONLY, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + title = Res.string.Settings_AutomatedTesting_RunAutomatically_ChargingOnly, + key = SettingsKey.AUTOMATED_TESTING_CHARGING, + type = PreferenceItemType.SWITCH, + ), + webCategory, + SettingsItem( + title = Res.string.Settings_Websites_MaxRuntimeEnabled, + key = SettingsKey.MAX_RUNTIME_ENABLED, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + title = Res.string.Settings_Websites_MaxRuntime, + key = SettingsKey.MAX_RUNTIME, + type = PreferenceItemType.TEXT, + ), + ), + footerContent = { + SettingsDescription( + Res.string.Settings_AutomatedTesting_RunAutomatically_Footer, + ) + }, + ), + SettingsCategoryItem( + icon = Res.drawable.privacy, + title = Res.string.Settings_Privacy_Label, + route = PreferenceCategoryKey.PRIVACY, + settings = + listOf( + SettingsItem( + title = Res.string.Settings_Sharing_UploadResults, + key = SettingsKey.UPLOAD_RESULTS, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + title = Res.string.Settings_Privacy_SendCrashReports, + key = SettingsKey.SEND_CRASH, + type = PreferenceItemType.SWITCH, + ), + ), + ), + SettingsCategoryItem( + icon = Res.drawable.proxy, + title = Res.string.Settings_Proxy_Label, + route = PreferenceCategoryKey.PROXY, + ), + SettingsCategoryItem( + icon = Res.drawable.advanced, + title = Res.string.Settings_Advanced_Label, + route = PreferenceCategoryKey.ADVANCED, + settings = + listOf( + SettingsItem( + title = Res.string.Settings_Advanced_LanguageSettings_Title, + key = SettingsKey.LANGUAGE_SETTING, + type = PreferenceItemType.SELECT, + ), + seeRecentLogsCategory, + SettingsItem( + title = Res.string.Settings_Advanced_DebugLogs, + key = SettingsKey.DEBUG_LOGS, + type = PreferenceItemType.SWITCH, + ), + SettingsItem( + title = Res.string.Settings_Storage_Label, + key = SettingsKey.STORAGE_SIZE, + type = PreferenceItemType.BUTTON, + ), + SettingsItem( + title = Res.string.Settings_WarmVPNInUse_Label, + key = SettingsKey.WARN_VPN_IN_USE, + type = PreferenceItemType.SWITCH, + ), + ), + ), + SettingsCategoryItem( + icon = Res.drawable.send_email, + title = Res.string.Settings_SendEmail_Label, + route = PreferenceCategoryKey.SEND_EMAIL, + ), + SettingsCategoryItem( + icon = Res.drawable.outline_info, + title = Res.string.Settings_About_Label, + route = PreferenceCategoryKey.ABOUT_OONI, + ), + ) + + fun getSettingsItem(route: PreferenceCategoryKey) = + (getSettingsItems() + listOf(webCategory, seeRecentLogsCategory)).first { + it.route == route + } + } +} + +enum class PreferenceItemType { + SWITCH, + TEXT, + BUTTON, + SELECT, + ROUTE, +} + +enum class PreferenceCategoryKey(val value: String) { + NOTIFICATIONS("notifications"), + TEST_OPTIONS("test_options"), + PRIVACY("privacy"), + PROXY("proxy"), + ADVANCED("advanced"), + SEND_EMAIL("send_email"), + ABOUT_OONI("about_ooni"), + + WEBSITES_CATEGORIES("websites_categories"), + SEE_RECENT_LOGS("see_recent_logs"), +} + +enum class SettingsKey(val value: String) { + // Notifications + NOTIFICATIONS_ENABLED("notifications_enabled"), + + // Test Options + AUTOMATED_TESTING_ENABLED("automated_testing_enabled"), + AUTOMATED_TESTING_WIFIONLY("automated_testing_wifionly"), + AUTOMATED_TESTING_CHARGING("automated_testing_charging"), + MAX_RUNTIME_ENABLED("max_runtime_enabled"), + MAX_RUNTIME("max_runtime"), + + // Website categories + SRCH("SRCH"), + PORN("PORN"), + COMM("COMM"), + COMT("COMT"), + MMED("MMED"), + HATE("HATE"), + POLR("POLR"), + PUBH("PUBH"), + GAME("GAME"), + PROV("PROV"), + HACK("HACK"), + MILX("MILX"), + DATE("DATE"), + ANON("ANON"), + ALDR("ALDR"), + GMB("GMB"), + XED("XED"), + REL("REL"), + GRP("GRP"), + GOVT("GOVT"), + ECON("ECON"), + LGBT("LGBT"), + FILE("FILE"), + HOST("HOST"), + HUMR("HUMR"), + NEWS("NEWS"), + ENV("ENV"), + CULTR("CULTR"), + CTRL("CTRL"), + IGO("IGO"), + + // Privacy + UPLOAD_RESULTS("upload_results"), + SEND_CRASH("send_crash"), + + // Proxy + PROXY_HOSTNAME("proxy_hostname"), + PROXY_PORT("proxy_port"), + + // Advanced + THEME_ENABLED("theme_enabled"), + LANGUAGE_SETTING("language_setting"), + DEBUG_LOGS("debugLogs"), + WARN_VPN_IN_USE("warn_vpn_in_use"), + STORAGE_SIZE("storage_size"), // purely decorative + + // MISC + DELETE_UPLOADED_JSONS("deleteUploadedJsons"), + IS_NOTIFICATION_DIALOG("isNotificationDialog"), + FIRST_RUN("first_run"), + + // Run Tests + TEST_SIGNAL("test_signal"), + RUN_HTTP_INVALID_REQUEST_LINE("run_http_invalid_request_line"), + TEST_FACEBOOK_MESSENGER("test_facebook_messenger"), + RUN_DASH("run_dash"), + WEB_CONNECTIVITY("web_connectivity"), + RUN_NDT("run_ndt"), + TEST_PSIPHON("test_psiphon"), + TEST_TOR("test_tor"), + PROXY_PROTOCOL("proxy_protocol"), + TEST_TELEGRAM("test_telegram"), + RUN_HTTP_HEADER_FIELD_MANIPULATION("run_http_header_field_manipulation"), + EXPERIMENTAL("experimental"), + TEST_WHATSAPP("test_whatsapp"), + + ROUTE("route"), +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/PreferenceRepository.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/PreferenceRepository.kt new file mode 100644 index 00000000..46c9f98e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/repositories/PreferenceRepository.kt @@ -0,0 +1,118 @@ +package org.ooni.probe.data.repositories + +import androidx.annotation.VisibleForTesting +import androidx.datastore.core.DataStore +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.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import org.ooni.probe.data.models.SettingsKey + +sealed class PreferenceKey(val preferenceKey: Preferences.Key) { + class IntKey(preferenceKey: Preferences.Key) : PreferenceKey(preferenceKey) + + class StringKey(preferenceKey: Preferences.Key) : PreferenceKey(preferenceKey) + + class BooleanKey(preferenceKey: Preferences.Key) : PreferenceKey(preferenceKey) + + class FloatKey(preferenceKey: Preferences.Key) : PreferenceKey(preferenceKey) + + class LongKey(preferenceKey: Preferences.Key) : PreferenceKey(preferenceKey) +} + +class PreferenceRepository( + private val dataStore: DataStore, +) { + /** + * This function is used to resolve the preference key for a given test. + * The preference key is the name of the test prefixed with the [prefix] of the descriptor + * and suffixed with "_autorun" if [autoRun] is true. + * + * Example: the preference key for the test "web_connectivity" in the + * descriptor "websites" is "websites_web_connectivity". + * If [autoRun] is true, the preference key is "websites_web_connectivity_autorun". + * + * @param name The name of the preference + * @param prefix The prefix of the preference + * @param autoRun If the preference is for auto run + * @return The preference key + */ + @VisibleForTesting + fun getPreferenceKey( + name: String, + prefix: String? = null, + autoRun: Boolean = false, + ): String { + return "${prefix?.let { "${it}_" } ?: ""}$name${if (autoRun) "_autorun" else ""}" + } + + private fun preferenceKeyFromSettingsKey( + key: SettingsKey, + prefix: String? = null, + autoRun: Boolean = false, + ): PreferenceKey<*> { + val preferenceKey = getPreferenceKey(name = key.value, prefix = prefix, autoRun = autoRun) + return when (key) { + SettingsKey.MAX_RUNTIME, + SettingsKey.PROXY_PORT, + -> PreferenceKey.IntKey(intPreferencesKey(preferenceKey)) + SettingsKey.PROXY_HOSTNAME, + SettingsKey.PROXY_PROTOCOL, + SettingsKey.LANGUAGE_SETTING, + -> PreferenceKey.StringKey(stringPreferencesKey(preferenceKey)) + else -> PreferenceKey.BooleanKey(booleanPreferencesKey(preferenceKey)) + } + } + + fun allSettings( + keys: List, + prefix: String? = null, + autoRun: Boolean = false, + ): Flow> = + dataStore.data.map { + keys.map { key -> key to it[preferenceKeyFromSettingsKey(key, prefix, autoRun).preferenceKey] }.toMap() + } + + fun getValueByKey(key: SettingsKey): Flow { + return dataStore.data.map { + when (val preferenceKey = preferenceKeyFromSettingsKey(key)) { + is PreferenceKey.IntKey -> it[preferenceKey.preferenceKey] + is PreferenceKey.StringKey -> it[preferenceKey.preferenceKey] + is PreferenceKey.BooleanKey -> it[preferenceKey.preferenceKey] + is PreferenceKey.FloatKey -> it[preferenceKey.preferenceKey] + is PreferenceKey.LongKey -> it[preferenceKey.preferenceKey] + } + } + } + + suspend fun setValueByKey( + key: SettingsKey, + value: T, + ) { + dataStore.edit { + when (val preferenceKey = preferenceKeyFromSettingsKey(key)) { + is PreferenceKey.IntKey -> it[preferenceKey.preferenceKey] = value as Int + is PreferenceKey.StringKey -> it[preferenceKey.preferenceKey] = value as String + is PreferenceKey.BooleanKey -> it[preferenceKey.preferenceKey] = value as Boolean + is PreferenceKey.FloatKey -> it[preferenceKey.preferenceKey] = value as Float + is PreferenceKey.LongKey -> it[preferenceKey.preferenceKey] = value as Long + } + } + } + + suspend fun clear() { + dataStore.edit { it.clear() } + } + + suspend fun remove(key: SettingsKey) { + dataStore.edit { it.remove(preferenceKeyFromSettingsKey(key).preferenceKey) } + } + + suspend fun contains(key: SettingsKey): Boolean { + return dataStore.data.map { it.contains(preferenceKeyFromSettingsKey(key).preferenceKey) }.firstOrNull() ?: false + } +} 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 2adf6f00..4b70e537 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -1,16 +1,24 @@ package org.ooni.probe.di import androidx.annotation.VisibleForTesting +import androidx.datastore.core.DataMigration +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences import app.cash.sqldelight.db.SqlDriver import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.serialization.json.Json +import okio.Path.Companion.toPath import org.ooni.engine.Engine import org.ooni.engine.NetworkTypeFinder import org.ooni.engine.OonimkallBridge import org.ooni.engine.TaskEventMapper import org.ooni.probe.Database +import org.ooni.probe.data.models.PreferenceCategoryKey import org.ooni.probe.data.models.ResultModel +import org.ooni.probe.data.models.SettingsCategoryItem +import org.ooni.probe.data.repositories.PreferenceRepository import org.ooni.probe.data.repositories.ResultRepository import org.ooni.probe.data.repositories.TestDescriptorRepository import org.ooni.probe.domain.BootstrapTestDescriptors @@ -23,6 +31,8 @@ import org.ooni.probe.shared.PlatformInfo import org.ooni.probe.ui.dashboard.DashboardViewModel import org.ooni.probe.ui.result.ResultViewModel import org.ooni.probe.ui.results.ResultsViewModel +import org.ooni.probe.ui.settings.SettingsViewModel +import org.ooni.probe.ui.settings.category.SettingsCategoryViewModel class Dependencies( val platformInfo: PlatformInfo, @@ -32,6 +42,7 @@ class Dependencies( private val readAssetFile: (String) -> String, private val databaseDriverFactory: () -> SqlDriver, private val networkTypeFinder: NetworkTypeFinder, + private val buildDataStore: () -> DataStore, ) { // Common @@ -83,6 +94,7 @@ class Dependencies( listInstalledTestDescriptors = testDescriptorRepository::list, ) } + private val preferenceManager by lazy { PreferenceRepository(buildDataStore()) } // ViewModels @@ -95,6 +107,19 @@ class Dependencies( fun resultsViewModel(goToResult: (ResultModel.Id) -> Unit) = ResultsViewModel(goToResult, getResults::invoke) + fun settingsViewModel(goToSettingsForCategory: (PreferenceCategoryKey) -> Unit) = SettingsViewModel(goToSettingsForCategory) + + fun settingsCategoryViewModel( + goToSettingsForCategory: (PreferenceCategoryKey) -> Unit, + onBack: () -> Unit, + category: SettingsCategoryItem, + ) = SettingsCategoryViewModel( + preferenceManager = preferenceManager, + onBack = onBack, + goToSettingsForCategory = goToSettingsForCategory, + category = category, + ) + fun resultViewModel( resultId: ResultModel.Id, onBack: () -> Unit, @@ -110,5 +135,25 @@ class Dependencies( @VisibleForTesting fun buildDatabase(driverFactory: () -> SqlDriver): Database = Database(driverFactory()) + + private lateinit var dataStore: DataStore + internal const val DATA_STORE_FILE_NAME = "probe.preferences_pb" + + /** + * Gets the singleton DataStore instance, creating it if necessary. + */ + fun getDataStore( + producePath: () -> String, + migrations: List> = listOf(), + ): DataStore = + if (::dataStore.isInitialized) { + dataStore + } else { + PreferenceDataStoreFactory.createWithPath( + produceFile = { producePath().toPath() }, + migrations = migrations, + ) + .also { dataStore = it } + } } } 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 63367cf5..23413d3f 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 @@ -9,12 +9,15 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import org.ooni.probe.data.models.PreferenceCategoryKey import org.ooni.probe.data.models.ResultModel +import org.ooni.probe.data.models.SettingsCategoryItem import org.ooni.probe.di.Dependencies import org.ooni.probe.ui.dashboard.DashboardScreen import org.ooni.probe.ui.result.ResultScreen import org.ooni.probe.ui.results.ResultsScreen import org.ooni.probe.ui.settings.SettingsScreen +import org.ooni.probe.ui.settings.category.SettingsCategoryScreen @Composable fun Navigation( @@ -44,7 +47,15 @@ fun Navigation( } composable(route = Screen.Settings.route) { - SettingsScreen() + val viewModel = + viewModel { + dependencies.settingsViewModel( + goToSettingsForCategory = { + navController.navigate(Screen.SettingsCategory(it).route) + }, + ) + } + SettingsScreen(viewModel::onEvent) } composable( @@ -62,5 +73,39 @@ fun Navigation( val state by viewModel.state.collectAsState() ResultScreen(state, viewModel::onEvent) } + + composable( + route = Screen.SettingsCategory.NAV_ROUTE, + arguments = Screen.SettingsCategory.ARGUMENTS, + ) { entry -> + val category = entry.arguments?.getString("category") ?: return@composable + when (category) { + PreferenceCategoryKey.SEND_EMAIL.name -> { + // TODO: Implement based on platform + } + + else -> { + val viewModel = + viewModel { + dependencies.settingsCategoryViewModel( + goToSettingsForCategory = { + navController.navigate(Screen.SettingsCategory(it).route) + }, + onBack = { navController.navigateUp() }, + category = + SettingsCategoryItem.getSettingsItem( + PreferenceCategoryKey.valueOf(category), + ), + ) + } + val state by viewModel.state.collectAsState() + + SettingsCategoryScreen( + state = state, + onEvent = 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 96650177..7bb2450a 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 @@ -2,6 +2,7 @@ package org.ooni.probe.ui.navigation import androidx.navigation.NavType import androidx.navigation.navArgument +import org.ooni.probe.data.models.PreferenceCategoryKey import org.ooni.probe.data.models.ResultModel sealed class Screen( @@ -21,4 +22,11 @@ sealed class Screen( val ARGUMENTS = listOf(navArgument("resultId") { type = NavType.LongType }) } } + + data class SettingsCategory(val category: PreferenceCategoryKey) : Screen("settings/${category.name}") { + companion object { + const val NAV_ROUTE = "settings/{category}" + val ARGUMENTS = listOf(navArgument("category") { type = NavType.StringType }) + } + } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/SettingsScreen.kt index d21c93bc..afa7580c 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/SettingsScreen.kt @@ -1,30 +1,65 @@ package org.ooni.probe.ui.settings +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ListItem import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import ooniprobe.composeapp.generated.resources.Res import ooniprobe.composeapp.generated.resources.settings +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource -import org.jetbrains.compose.ui.tooling.preview.Preview -import org.ooni.probe.ui.theme.AppTheme +import org.ooni.probe.data.models.SettingsCategoryItem @Composable -fun SettingsScreen() { +fun SettingsScreen(onNavigateToSettingsCategory: (SettingsViewModel.Event) -> Unit) { Column { TopAppBar( title = { Text(stringResource(Res.string.settings)) }, ) + + SettingsCategoryItem.getSettingsItems().forEach { item -> + SettingsItemView( + icon = item.icon, + title = item.title, + modifier = + Modifier.clickable { + onNavigateToSettingsCategory( + item.routeToSettingsCategory(), + ) + }, + ) + } } } -@Preview @Composable -fun SettingsScreenPreview() { - AppTheme { - SettingsScreen() - } +fun SettingsItemView( + icon: DrawableResource?, + title: StringResource, + modifier: Modifier, +) { + ListItem( + leadingContent = { + icon?.let { + Image( + modifier = Modifier.height(24.dp).width(24.dp), + painter = painterResource(it), + contentDescription = stringResource(title), + ) + } + }, + headlineContent = { Text(stringResource(title)) }, + modifier = modifier, + ) } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/SettingsViewModel.kt new file mode 100644 index 00000000..0be5832c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/SettingsViewModel.kt @@ -0,0 +1,28 @@ +package org.ooni.probe.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.ooni.probe.data.models.PreferenceCategoryKey + +open class SettingsViewModel( + goToSettingsForCategory: (PreferenceCategoryKey) -> Unit, +) : ViewModel() { + private val events = MutableSharedFlow(extraBufferCapacity = 1) + + init { + events.filterIsInstance() + .onEach { goToSettingsForCategory(it.category) }.launchIn(viewModelScope) + } + + fun onEvent(event: Event) { + events.tryEmit(event) + } + + sealed interface Event { + data class SettingsCategoryClick(val category: PreferenceCategoryKey) : Event + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/category/SettingsCategoryScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/category/SettingsCategoryScreen.kt new file mode 100644 index 00000000..38ae604d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/category/SettingsCategoryScreen.kt @@ -0,0 +1,185 @@ +package org.ooni.probe.ui.settings.category + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.back +import ooniprobe.composeapp.generated.resources.settings +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.ooni.probe.data.models.PreferenceCategoryKey +import org.ooni.probe.data.models.PreferenceItemType +import org.ooni.probe.data.models.SettingsCategoryItem +import org.ooni.probe.data.models.SettingsKey + +@Composable +fun SettingsCategoryScreen( + state: SettingsCategoryViewModel.State, + onEvent: (SettingsCategoryViewModel.Event) -> Unit, +) { + Column { + TopAppBar( + title = { + Text(stringResource(state.category.title)) + }, + navigationIcon = { + IconButton(onClick = { onEvent(SettingsCategoryViewModel.Event.BackClicked) }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back), + ) + } + }, + ) + Box( + modifier = Modifier.verticalScroll(rememberScrollState()).padding(bottom = 48.dp), + ) { + Column { + state.category.settings?.forEach { preferenceItem -> + when (preferenceItem.type) { + PreferenceItemType.SWITCH -> + SwitchSettingsView( + leadingContent = + preferenceItem.icon?.let { + { + Image( + modifier = Modifier.height(24.dp).width(24.dp), + painter = painterResource(it), + contentDescription = stringResource(preferenceItem.title), + ) + } + }, + title = preferenceItem.title, + key = preferenceItem.key, + checked = + state.preference?.let { it[preferenceItem.key] as? Boolean } + ?: false, + supportingContent = preferenceItem.supportingContent, + onCheckedChange = { key, value -> + onEvent( + SettingsCategoryViewModel.Event.CheckedChangeClick( + key, + value, + ), + ) + }, + ) + + PreferenceItemType.TEXT -> + RouteSettingsView( + title = preferenceItem.title, + supportingContent = preferenceItem.supportingContent, + ) + + PreferenceItemType.BUTTON -> + RouteSettingsView( + title = preferenceItem.title, + supportingContent = preferenceItem.supportingContent, + trailingContent = { + Button( + onClick = {}, + ) { + Text(stringResource(Res.string.settings)) + } + }, + ) + + PreferenceItemType.ROUTE -> + RouteSettingsView( + title = preferenceItem.title, + supportingContent = preferenceItem.supportingContent, + modifier = + Modifier.clickable { + if (preferenceItem is SettingsCategoryItem) { + onEvent( + SettingsCategoryViewModel.Event.SettingsCategoryClick( + PreferenceCategoryKey.valueOf(preferenceItem.route.name), + ), + ) + } + }, + ) + + PreferenceItemType.SELECT -> + RouteSettingsView( + title = preferenceItem.title, + supportingContent = preferenceItem.supportingContent, + ) + } + } + state.category.footerContent?.invoke() + } + } + } +} + +@Composable +fun SwitchSettingsView( + title: StringResource, + supportingContent: @Composable (() -> Unit)? = null, + leadingContent: @Composable (() -> Unit)? = null, + key: SettingsKey, + checked: Boolean, + onCheckedChange: (SettingsKey, Boolean) -> Unit, +) { + ListItem( + headlineContent = { Text(stringResource(title)) }, + supportingContent = supportingContent, + leadingContent = leadingContent, + trailingContent = { + Switch( + checked = checked, + onCheckedChange = { newValue -> onCheckedChange(key, newValue) }, + modifier = Modifier.scale(0.7f), + ) + }, + ) +} + +@Composable +fun RouteSettingsView( + title: StringResource, + supportingContent: @Composable (() -> Unit)? = null, + leadingContent: @Composable (() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + ListItem( + headlineContent = { Text(stringResource(title)) }, + supportingContent = supportingContent, + leadingContent = leadingContent, + trailingContent = trailingContent, + modifier = modifier, + ) +} + +@Composable +fun SettingsDescription(description: StringResource) { + Text( + stringResource(description), + modifier = Modifier.padding(horizontal = 16.dp), + fontSize = 12.sp, + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/category/SettingsCategoryViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/category/SettingsCategoryViewModel.kt new file mode 100644 index 00000000..c58c23fc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/category/SettingsCategoryViewModel.kt @@ -0,0 +1,64 @@ +package org.ooni.probe.ui.settings.category + +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.data.models.PreferenceCategoryKey +import org.ooni.probe.data.models.SettingsCategoryItem +import org.ooni.probe.data.models.SettingsKey +import org.ooni.probe.data.repositories.PreferenceRepository + +class SettingsCategoryViewModel( + preferenceManager: PreferenceRepository, + goToSettingsForCategory: (PreferenceCategoryKey) -> Unit, + onBack: () -> Unit, + category: SettingsCategoryItem, +) : ViewModel() { + private val events = MutableSharedFlow(extraBufferCapacity = 1) + + private val _state = MutableStateFlow(State(preference = null, category = category)) + val state = _state.asStateFlow() + + init { + category.settings?.map { item -> item.key }?.let { preferenceKeys -> + preferenceManager.allSettings(preferenceKeys) + .onEach { result -> _state.update { it.copy(preference = result) } } + .launchIn(viewModelScope) + } + + events.filterIsInstance() + .onEach { goToSettingsForCategory(it.category) }.launchIn(viewModelScope) + + events.filterIsInstance().onEach { + preferenceManager.setValueByKey( + key = it.key, + value = it.value, + ) + }.launchIn(viewModelScope) + + events.filterIsInstance().onEach { onBack() }.launchIn(viewModelScope) + } + + fun onEvent(event: Event) { + events.tryEmit(event) + } + + data class State( + val preference: Map?, + val category: SettingsCategoryItem, + ) + + sealed interface Event { + data class SettingsCategoryClick(val category: PreferenceCategoryKey) : Event + + data class CheckedChangeClick(val key: SettingsKey, val value: Boolean) : Event + + data object BackClicked : Event + } +} diff --git a/composeApp/src/commonTest/kotlin/org/ooni/probe/data/repositories/PreferenceRepositoryTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/data/repositories/PreferenceRepositoryTest.kt new file mode 100644 index 00000000..d87370f1 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/data/repositories/PreferenceRepositoryTest.kt @@ -0,0 +1,104 @@ +package org.ooni.probe.data.repositories + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.ooni.probe.data.models.SettingsKey +import org.ooni.testing.createPreferenceDataStore +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PreferenceRepositoryTest { + private lateinit var preferenceRepository: PreferenceRepository + + @BeforeTest + fun before() { + preferenceRepository = PreferenceRepository(createPreferenceDataStore()) + } + + @AfterTest + fun after() = + runTest { + preferenceRepository.clear() + } + + @Test + fun testAllSettings() = + runTest { + val value = "value" + preferenceRepository.setValueByKey(SettingsKey.LANGUAGE_SETTING, value) + val setting: Map = preferenceRepository.allSettings(listOf(SettingsKey.LANGUAGE_SETTING)).first() + assertEquals(value, setting.values.first()) + } + + @Test + fun testGetPreferenceKey() { + assertEquals( + SettingsKey.LANGUAGE_SETTING.value, + preferenceRepository.getPreferenceKey(SettingsKey.LANGUAGE_SETTING.value), + ) + assertEquals( + "prefix_${SettingsKey.LANGUAGE_SETTING.value}", + preferenceRepository.getPreferenceKey(SettingsKey.LANGUAGE_SETTING.value, "prefix"), + ) + assertEquals( + "${SettingsKey.LANGUAGE_SETTING.value}_autorun", + preferenceRepository.getPreferenceKey( + SettingsKey.LANGUAGE_SETTING.value, + autoRun = true, + ), + ) + assertEquals( + "prefix_${SettingsKey.LANGUAGE_SETTING.value}_autorun", + preferenceRepository.getPreferenceKey( + SettingsKey.LANGUAGE_SETTING.value, + "prefix", + true, + ), + ) + } + + @Test + fun testGetValueByKey() = + runTest { + val value = "value" + preferenceRepository.setValueByKey(SettingsKey.LANGUAGE_SETTING, value) + assertEquals(value, preferenceRepository.getValueByKey(key = SettingsKey.LANGUAGE_SETTING).first()) + } + + @Test + fun testSetValueByKey() = + runTest { + val value = "value" + preferenceRepository.setValueByKey(SettingsKey.LANGUAGE_SETTING, value) + assertEquals(value, preferenceRepository.getValueByKey(SettingsKey.LANGUAGE_SETTING).first()) + } + + @Test + fun testClear() = + runTest { + val value = "value" + preferenceRepository.setValueByKey(SettingsKey.LANGUAGE_SETTING, value) + preferenceRepository.clear() + assertNull(preferenceRepository.getValueByKey(SettingsKey.LANGUAGE_SETTING).first()) + } + + @Test + fun testRemove() = + runTest { + val value = "value" + preferenceRepository.setValueByKey(SettingsKey.LANGUAGE_SETTING, value) + preferenceRepository.remove(SettingsKey.LANGUAGE_SETTING) + assertNull(preferenceRepository.getValueByKey(SettingsKey.LANGUAGE_SETTING).first()) + } + + @Test + fun testContains() = + runTest { + val value = "value" + preferenceRepository.setValueByKey(SettingsKey.LANGUAGE_SETTING, value) + assertEquals(true, preferenceRepository.contains(SettingsKey.LANGUAGE_SETTING)) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/ooni/testing/CreatePreferenceDataStore.kt b/composeApp/src/commonTest/kotlin/org/ooni/testing/CreatePreferenceDataStore.kt new file mode 100644 index 00000000..6b6baa1c --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/ooni/testing/CreatePreferenceDataStore.kt @@ -0,0 +1,6 @@ +package org.ooni.testing + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences + +internal expect fun createPreferenceDataStore(): DataStore diff --git a/composeApp/src/dwMain/composeResources/values/strings-organization.xml b/composeApp/src/dwMain/composeResources/values/strings-organization.xml index 3cbb405a..f29fd870 100644 --- a/composeApp/src/dwMain/composeResources/values/strings-organization.xml +++ b/composeApp/src/dwMain/composeResources/values/strings-organization.xml @@ -1,3 +1,5 @@ News Media Scan + + About News Media Scan diff --git a/composeApp/src/iosMain/kotlin/org/ooni/probe/SetupDependencies.kt b/composeApp/src/iosMain/kotlin/org/ooni/probe/SetupDependencies.kt index cd0773e5..1f9e6a8d 100644 --- a/composeApp/src/iosMain/kotlin/org/ooni/probe/SetupDependencies.kt +++ b/composeApp/src/iosMain/kotlin/org/ooni/probe/SetupDependencies.kt @@ -1,5 +1,7 @@ package org.ooni.probe +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import app.cash.sqldelight.driver.native.NativeSqliteDriver import org.ooni.engine.NetworkTypeFinder import org.ooni.engine.OonimkallBridge @@ -8,9 +10,11 @@ import org.ooni.probe.shared.Platform import org.ooni.probe.shared.PlatformInfo import platform.Foundation.NSBundle import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSFileManager import platform.Foundation.NSSearchPathForDirectoriesInDomains import platform.Foundation.NSString import platform.Foundation.NSTemporaryDirectory +import platform.Foundation.NSURL import platform.Foundation.NSUserDomainMask import platform.Foundation.stringWithContentsOfFile import platform.UIKit.UIDevice @@ -37,6 +41,7 @@ fun setupDependencies( readAssetFile = ::readAssetFile, databaseDriverFactory = ::buildDatabaseDriver, networkTypeFinder = networkTypeFinder, + buildDataStore = ::buildDataStore, ) private val platformInfo @@ -79,3 +84,18 @@ private fun readAssetFile(path: String): String { private class BundleMarker : NSObject() { companion object : NSObjectMeta() } + +fun buildDataStore(): DataStore = + Dependencies.getDataStore( + producePath = { + val documentDirectory: NSURL? = + NSFileManager.defaultManager.URLForDirectory( + directory = NSDocumentDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null, + ) + requireNotNull(documentDirectory).path + "/${Dependencies.Companion.DATA_STORE_FILE_NAME}" + }, + ) diff --git a/composeApp/src/iosTest/kotlin/org/ooni/testing/CreatePreferenceDataStore.kt b/composeApp/src/iosTest/kotlin/org/ooni/testing/CreatePreferenceDataStore.kt new file mode 100644 index 00000000..3dc472fd --- /dev/null +++ b/composeApp/src/iosTest/kotlin/org/ooni/testing/CreatePreferenceDataStore.kt @@ -0,0 +1,25 @@ +package org.ooni.testing + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import org.ooni.probe.di.Dependencies +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSURL +import platform.Foundation.NSUserDomainMask + +internal actual fun createPreferenceDataStore(): DataStore { + return Dependencies.getDataStore( + producePath = { + val documentDirectory: NSURL? = + NSFileManager.defaultManager.URLForDirectory( + directory = NSDocumentDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null, + ) + requireNotNull(documentDirectory).path + "/test.${Dependencies.Companion.DATA_STORE_FILE_NAME}" + }, + ) +} diff --git a/composeApp/src/ooniMain/composeResources/values/strings-organization.xml b/composeApp/src/ooniMain/composeResources/values/strings-organization.xml index 4504147e..2201e65d 100644 --- a/composeApp/src/ooniMain/composeResources/values/strings-organization.xml +++ b/composeApp/src/ooniMain/composeResources/values/strings-organization.xml @@ -24,4 +24,6 @@ ~ 8 MB < 1 MB 5 - 200 MB + + About OONI diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab48db9b..bfb28e06 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ android-targetSdk = "34" compose-plugin = "1.6.11" kotlin = "2.0.0" sqldelight = "2.0.2" +dataStoreVersion = "1.1.1" [plugins] @@ -33,6 +34,10 @@ android-activity = { module = "androidx.activity:activity-ktx", version = "1.9.1 lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version = "2.8.0" } navigation = { module = "org.jetbrains.androidx.navigation:navigation-compose", version = "2.7.0-alpha07" } +# Preferences +androidx-datastore-core-okio = { group = "androidx.datastore", name = "datastore-core-okio", version.ref = "dataStoreVersion" } +androidx-datastore-preferences-core = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "dataStoreVersion" } + # Engine android-oonimkall = { module = "org.ooni:oonimkall", version = "2024.05.22-092559" } @@ -60,6 +65,8 @@ ui = [ tooling = [ "kermit", "sqldelight-coroutines", + "androidx-datastore-core-okio", + "androidx-datastore-preferences-core", ] android-test = [ "android-test-core"