diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_add.xml b/composeApp/src/commonMain/composeResources/drawable/ic_add.xml
new file mode 100644
index 000000000..19a0a9c8a
--- /dev/null
+++ b/composeApp/src/commonMain/composeResources/drawable/ic_add.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_cancel.xml b/composeApp/src/commonMain/composeResources/drawable/ic_cancel.xml
new file mode 100644
index 000000000..f1ce55098
--- /dev/null
+++ b/composeApp/src/commonMain/composeResources/drawable/ic_cancel.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml
index 37cfb43e3..91f3c117d 100644
--- a/composeApp/src/commonMain/composeResources/values/strings-common.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml
@@ -3,6 +3,7 @@
Last test:
Estimated:
N/A
+ Choose websites
Running:
Estimated time left:
@@ -214,6 +215,15 @@
UPDATED
UPDATE
+ Choose websites to test
+ URL
+ No URLs entered
+ Run
+ Add website
+ Test %1$d URLs
+ Are you sure?
+ Your URLs will not be saved when you leave this screen. Are you sure you want to leave this screen?
+
Back
refresh
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 f3bb442b6..e4f8b543c 100644
--- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt
+++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt
@@ -74,6 +74,7 @@ import org.ooni.probe.domain.UploadMissingMeasurements
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.choosewebsites.ChooseWebsitesViewModel
import org.ooni.probe.ui.dashboard.DashboardViewModel
import org.ooni.probe.ui.descriptor.DescriptorViewModel
import org.ooni.probe.ui.descriptor.add.AddDescriptorViewModel
@@ -351,6 +352,15 @@ class Dependencies(
fetchDescriptor = { fetchDescriptor(descriptorId) },
)
+ fun chooseWebsitesViewModel(
+ onBack: () -> Unit,
+ goToDashboard: () -> Unit,
+ ) = ChooseWebsitesViewModel(
+ onBack = onBack,
+ goToDashboard = goToDashboard,
+ startBackgroundRun = startSingleRunInner,
+ )
+
fun dashboardViewModel(
goToOnboarding: () -> Unit,
goToResults: () -> Unit,
@@ -380,10 +390,12 @@ class Dependencies(
descriptorKey: String,
onBack: () -> Unit,
goToReviewDescriptorUpdates: () -> Unit,
+ goToChooseWebsites: () -> Unit,
) = DescriptorViewModel(
descriptorKey = descriptorKey,
onBack = onBack,
goToReviewDescriptorUpdates = goToReviewDescriptorUpdates,
+ goToChooseWebsites = goToChooseWebsites,
getTestDescriptors = getTestDescriptors::invoke,
getDescriptorLastResult = resultRepository::getLatestByDescriptor,
preferenceRepository = preferenceRepository,
diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetTestDescriptorsBySpec.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetTestDescriptorsBySpec.kt
index 02b505c22..4ec91cd5d 100644
--- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetTestDescriptorsBySpec.kt
+++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetTestDescriptorsBySpec.kt
@@ -16,15 +16,11 @@ class GetTestDescriptorsBySpec(
.mapNotNull { descriptor ->
val specTest = spec.forDescriptor(descriptor) ?: return@mapNotNull null
- val specDescriptor =
- descriptor.copy(
- netTests =
- descriptor.netTests
- .filter { specTest.netTests.contains(it) },
- longRunningTests =
- descriptor.longRunningTests
- .filter { specTest.netTests.contains(it) },
- )
+ val specDescriptor = descriptor.copy(
+ netTests = specTest.netTests,
+ // long running are already inside netTests
+ longRunningTests = emptyList(),
+ )
if (specDescriptor.netTests.isEmpty() && specDescriptor.longRunningTests.isEmpty()) {
return@mapNotNull null
diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt
new file mode 100644
index 000000000..91631c067
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt
@@ -0,0 +1,175 @@
+package org.ooni.probe.ui.choosewebsites
+
+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.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+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.CustomWebsites_Fab_Text
+import ooniprobe.composeapp.generated.resources.Modal_Cancel
+import ooniprobe.composeapp.generated.resources.Modal_CustomURL_NotSaved
+import ooniprobe.composeapp.generated.resources.Modal_CustomURL_Title_NotSaved
+import ooniprobe.composeapp.generated.resources.Modal_Delete
+import ooniprobe.composeapp.generated.resources.Modal_OK
+import ooniprobe.composeapp.generated.resources.Res
+import ooniprobe.composeapp.generated.resources.Settings_Websites_CustomURL_Add
+import ooniprobe.composeapp.generated.resources.Settings_Websites_CustomURL_Title
+import ooniprobe.composeapp.generated.resources.Settings_Websites_CustomURL_URL
+import ooniprobe.composeapp.generated.resources.back
+import ooniprobe.composeapp.generated.resources.ic_add
+import ooniprobe.composeapp.generated.resources.ic_cancel
+import ooniprobe.composeapp.generated.resources.ic_timer
+import org.jetbrains.compose.resources.painterResource
+import org.jetbrains.compose.resources.stringResource
+import org.ooni.probe.ui.shared.ColorDefaults
+
+@Composable
+fun ChooseWebsitesScreen(
+ state: ChooseWebsitesViewModel.State,
+ onEvent: (ChooseWebsitesViewModel.Event) -> Unit,
+) {
+ Column(
+ modifier = Modifier.padding(WindowInsets.navigationBars.asPaddingValues()),
+ ) {
+ TopAppBar(
+ title = { Text(stringResource(Res.string.Settings_Websites_CustomURL_Title)) },
+ navigationIcon = {
+ IconButton(onClick = { onEvent(ChooseWebsitesViewModel.Event.BackClicked) }) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(Res.string.back),
+ )
+ }
+ },
+ colors = ColorDefaults.topAppBar(),
+ )
+
+ Box(Modifier.fillMaxSize()) {
+ LazyColumn(
+ contentPadding = PaddingValues(
+ bottom = WindowInsets.navigationBars.asPaddingValues()
+ .calculateBottomPadding() + 64.dp,
+ ),
+ ) {
+ itemsIndexed(state.websites) { index, item ->
+ OutlinedTextField(
+ value = item.url,
+ onValueChange = {
+ onEvent(
+ ChooseWebsitesViewModel.Event.UrlChanged(
+ index,
+ it,
+ ),
+ )
+ },
+ label = { stringResource(Res.string.Settings_Websites_CustomURL_URL) },
+ isError = item.hasError,
+ trailingIcon = {
+ if (state.canRemoveUrls) {
+ IconButton(
+ onClick = {
+ onEvent(
+ ChooseWebsitesViewModel.Event.DeleteWebsiteClicked(
+ index,
+ ),
+ )
+ },
+ ) {
+ Icon(
+ painterResource(Res.drawable.ic_cancel),
+ contentDescription = stringResource(Res.string.Modal_Delete),
+ )
+ }
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(top = 16.dp),
+ )
+ }
+
+ item(key = "Add") {
+ TextButton(
+ onClick = { onEvent(ChooseWebsitesViewModel.Event.AddWebsiteClicked) },
+ modifier = Modifier.padding(16.dp),
+ ) {
+ Icon(
+ painterResource(Res.drawable.ic_add),
+ contentDescription = null,
+ modifier = Modifier.padding(end = 8.dp),
+ )
+ Text(stringResource(Res.string.Settings_Websites_CustomURL_Add))
+ }
+ }
+ }
+
+ Button(
+ onClick = { onEvent(ChooseWebsitesViewModel.Event.RunClicked) },
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(WindowInsets.navigationBars.asPaddingValues())
+ .padding(bottom = 16.dp),
+ ) {
+ Icon(
+ painterResource(Res.drawable.ic_timer),
+ contentDescription = null,
+ modifier = Modifier.padding(end = 8.dp),
+ )
+ Text(
+ stringResource(Res.string.CustomWebsites_Fab_Text, state.websites.size),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ }
+ }
+
+ if (state.showBackConfirmation) {
+ BackConfirmationDialog(
+ onConfirm = { onEvent(ChooseWebsitesViewModel.Event.BackConfirmed) },
+ onDismiss = { onEvent(ChooseWebsitesViewModel.Event.BackCancelled) },
+ )
+ }
+}
+
+@Composable
+private fun BackConfirmationDialog(
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = { onDismiss() },
+ title = { Text(stringResource(Res.string.Modal_CustomURL_Title_NotSaved)) },
+ text = { Text(stringResource(Res.string.Modal_CustomURL_NotSaved)) },
+ confirmButton = {
+ TextButton(onClick = { onConfirm() }) {
+ Text(stringResource(Res.string.Modal_OK))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { onDismiss() }) {
+ Text(stringResource(Res.string.Modal_Cancel))
+ }
+ },
+ )
+}
diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesViewModel.kt
new file mode 100644
index 000000000..81d1f03cb
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesViewModel.kt
@@ -0,0 +1,147 @@
+package org.ooni.probe.ui.choosewebsites
+
+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.engine.models.TaskOrigin
+import org.ooni.engine.models.TestType
+import org.ooni.probe.data.models.NetTest
+import org.ooni.probe.data.models.RunSpecification
+import org.ooni.probe.ui.shared.isValidUrl
+
+class ChooseWebsitesViewModel(
+ onBack: () -> Unit,
+ goToDashboard: () -> Unit,
+ startBackgroundRun: (RunSpecification) -> Unit,
+) : ViewModel() {
+ private val events = MutableSharedFlow(extraBufferCapacity = 1)
+
+ private val _state = MutableStateFlow(State())
+ val state = _state.asStateFlow()
+
+ init {
+ events
+ .filterIsInstance()
+ .onEach {
+ if (_state.value == State()) {
+ onBack()
+ } else {
+ _state.update { it.copy(showBackConfirmation = true) }
+ }
+ }
+ .launchIn(viewModelScope)
+
+ events
+ .filterIsInstance()
+ .onEach { _state.update { it.copy(showBackConfirmation = false) } }
+ .launchIn(viewModelScope)
+
+ events
+ .filterIsInstance()
+ .onEach { onBack() }
+ .launchIn(viewModelScope)
+
+ events
+ .filterIsInstance()
+ .onEach { _state.update { it.copy(websites = it.websites + WebsiteItem()) } }
+ .launchIn(viewModelScope)
+
+ events
+ .filterIsInstance()
+ .onEach { event ->
+ if (_state.value.websites.size <= 1) return@onEach
+ _state.update {
+ val newList = it.websites.filterIndexed { index, _ -> index != event.index }
+ it.copy(websites = newList)
+ }
+ }
+ .launchIn(viewModelScope)
+
+ events
+ .filterIsInstance()
+ .onEach { event ->
+ _state.update {
+ it.copy(
+ websites = it.websites.toMutableList()
+ .also { list ->
+ list[event.index] = list[event.index]
+ .copy(url = event.url, hasError = false)
+ }
+ .toList(),
+ )
+ }
+ }
+ .launchIn(viewModelScope)
+
+ events
+ .filterIsInstance()
+ .onEach {
+ val items = _state.value.websites.map { item ->
+ item.copy(hasError = !item.url.isValidUrl())
+ }
+
+ if (items.any { it.hasError }) {
+ _state.value = _state.value.copy(websites = items)
+ return@onEach
+ }
+
+ startBackgroundRun(
+ RunSpecification(
+ tests = listOf(
+ RunSpecification.Test(
+ source = RunSpecification.Test.Source.Default("websites"),
+ netTests = listOf(
+ NetTest(
+ test = TestType.WebConnectivity,
+ inputs = items.map { it.url },
+ ),
+ ),
+ ),
+ ),
+ taskOrigin = TaskOrigin.OoniRun,
+ isRerun = false,
+ ),
+ )
+ goToDashboard()
+ }
+ .launchIn(viewModelScope)
+ }
+
+ fun onEvent(event: Event) {
+ events.tryEmit(event)
+ }
+
+ data class WebsiteItem(
+ val url: String = "http://",
+ val hasError: Boolean = false,
+ )
+
+ data class State(
+ val websites: List = listOf(WebsiteItem()),
+ val showBackConfirmation: Boolean = false,
+ ) {
+ val canRemoveUrls get() = websites.size > 1
+ }
+
+ sealed interface Event {
+ data object BackClicked : Event
+
+ data object BackConfirmed : Event
+
+ data object BackCancelled : Event
+
+ data class UrlChanged(val index: Int, val url: String) : Event
+
+ data class DeleteWebsiteClicked(val index: Int) : Event
+
+ data object AddWebsiteClicked : Event
+
+ data object RunClicked : Event
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorScreen.kt
index 9b9a61363..3b191b362 100644
--- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorScreen.kt
+++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorScreen.kt
@@ -15,6 +15,7 @@ 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.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -30,11 +31,13 @@ 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.SolidColor
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ooniprobe.composeapp.generated.resources.AddDescriptor_AutoRun
import ooniprobe.composeapp.generated.resources.AddDescriptor_Settings
+import ooniprobe.composeapp.generated.resources.Dashboard_Overview_ChooseWebsites
import ooniprobe.composeapp.generated.resources.Dashboard_Overview_Estimated
import ooniprobe.composeapp.generated.resources.Dashboard_Overview_LastRun_Never
import ooniprobe.composeapp.generated.resources.Dashboard_Overview_LatestTest
@@ -110,64 +113,7 @@ fun DescriptorScreen(
.padding(WindowInsets.navigationBars.asPaddingValues())
.padding(bottom = 32.dp),
) {
- Surface(
- color = descriptorColor,
- contentColor = onDescriptorColor,
- ) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- modifier = Modifier.fillMaxWidth()
- .padding(8.dp),
- ) {
- descriptor.icon?.let { icon ->
- Icon(
- painterResource(icon),
- contentDescription = null,
- tint = onDescriptorColor,
- modifier = Modifier.size(64.dp),
- )
- }
-
- Row {
- Text(stringResource(Res.string.Dashboard_Overview_Estimated))
-
- descriptor.dataUsage()?.let { dataUsage ->
- Text(
- text = dataUsage,
- fontWeight = FontWeight.Bold,
- modifier = Modifier.padding(start = 8.dp),
- )
- }
-
- state.estimatedTime?.let { time ->
- Text(
- text = "~ ${time.shortFormat()}",
- fontWeight = FontWeight.Bold,
- modifier = Modifier.padding(start = 8.dp),
- )
- }
- }
-
- Row {
- Text(stringResource(Res.string.Dashboard_Overview_LatestTest))
-
- Text(
- text = state.lastResult?.startTime?.relativeDateTime()
- ?: stringResource(Res.string.Dashboard_Overview_LastRun_Never),
- fontWeight = FontWeight.Bold,
- modifier = Modifier.padding(start = 8.dp),
- )
- }
- if (descriptor.updatable) {
- UpdatesChip(onClick = { })
- }
- state.updatedDescriptor?.let {
- OutlinedButton(onClick = { onEvent(DescriptorViewModel.Event.UpdateDescriptor) }) {
- Text(stringResource(Res.string.Dashboard_Runv2_Overview_ReviewUpdates))
- }
- }
- }
- }
+ DescriptorDetails(state, onEvent)
MarkdownViewer(
markdown = descriptor.description().orEmpty(),
@@ -230,6 +176,96 @@ fun DescriptorScreen(
}
}
+@Composable
+private fun DescriptorDetails(
+ state: DescriptorViewModel.State,
+ onEvent: (DescriptorViewModel.Event) -> Unit,
+) {
+ val descriptor = state.descriptor ?: return
+ val descriptorColor = descriptor.color ?: MaterialTheme.colorScheme.primary
+ val onDescriptorColor = LocalCustomColors.current.onDescriptor
+
+ Surface(
+ color = descriptorColor,
+ contentColor = onDescriptorColor,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.fillMaxWidth()
+ .padding(8.dp),
+ ) {
+ descriptor.icon?.let { icon ->
+ Icon(
+ painterResource(icon),
+ contentDescription = null,
+ tint = onDescriptorColor,
+ modifier = Modifier.size(64.dp),
+ )
+ }
+
+ Row {
+ Text(stringResource(Res.string.Dashboard_Overview_Estimated))
+
+ descriptor.dataUsage()?.let { dataUsage ->
+ Text(
+ text = dataUsage,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+
+ state.estimatedTime?.let { time ->
+ Text(
+ text = "~ ${time.shortFormat()}",
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ }
+
+ Row {
+ Text(stringResource(Res.string.Dashboard_Overview_LatestTest))
+
+ Text(
+ text = state.lastResult?.startTime?.relativeDateTime()
+ ?: stringResource(Res.string.Dashboard_Overview_LastRun_Never),
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+
+ if (descriptor.name == "websites") {
+ OutlinedButton(
+ onClick = { onEvent(DescriptorViewModel.Event.ChooseWebsitesClicked) },
+ border = ButtonDefaults.outlinedButtonBorder.copy(
+ brush = SolidColor(onDescriptorColor),
+ ),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = onDescriptorColor),
+ modifier = Modifier.padding(top = 8.dp),
+ ) {
+ Text(stringResource(Res.string.Dashboard_Overview_ChooseWebsites))
+ }
+ }
+
+ if (descriptor.updatable) {
+ UpdatesChip(onClick = { }, modifier = Modifier.padding(top = 8.dp))
+ }
+ state.updatedDescriptor?.let {
+ OutlinedButton(
+ onClick = { onEvent(DescriptorViewModel.Event.UpdateDescriptor) },
+ border = ButtonDefaults.outlinedButtonBorder.copy(
+ brush = SolidColor(onDescriptorColor),
+ ),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = onDescriptorColor),
+ modifier = Modifier.padding(top = 8.dp),
+ ) {
+ Text(stringResource(Res.string.Dashboard_Runv2_Overview_ReviewUpdates))
+ }
+ }
+ }
+ }
+}
+
@Composable
private fun TestItems(
descriptor: Descriptor,
diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorViewModel.kt
index e54a10f18..449f9a25c 100644
--- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorViewModel.kt
@@ -33,6 +33,7 @@ class DescriptorViewModel(
private val descriptorKey: String,
onBack: () -> Unit,
goToReviewDescriptorUpdates: () -> Unit,
+ goToChooseWebsites: () -> Unit,
private val getTestDescriptors: () -> Flow>,
getDescriptorLastResult: (String) -> Flow,
private val preferenceRepository: PreferenceRepository,
@@ -50,29 +51,36 @@ class DescriptorViewModel(
val state = _state.asStateFlow()
init {
-
- descriptorUpdates().onEach { results ->
- _state.update {
- it.copy(
- refreshType = if (results.refreshType != UpdateStatusType.ReviewLink) results.refreshType else UpdateStatusType.None,
- )
- }
- if (results.availableUpdates.size == 1) {
- results.availableUpdates.first().let { updatedDescriptor ->
- if (updatedDescriptor.id.value == descriptorKey.toLongOrNull()) {
- _state.update {
- it.copy(
- updatedDescriptor = updatedDescriptor.toDescriptor(),
- refreshType = UpdateStatusType.None,
- )
+ descriptorUpdates()
+ .onEach { results ->
+ _state.update {
+ it.copy(
+ refreshType = if (results.refreshType != UpdateStatusType.ReviewLink) {
+ results.refreshType
+ } else {
+ UpdateStatusType.None
+ },
+ )
+ }
+ if (results.availableUpdates.size == 1) {
+ results.availableUpdates.first().let { updatedDescriptor ->
+ if (updatedDescriptor.id.value == descriptorKey.toLongOrNull()) {
+ _state.update {
+ it.copy(
+ updatedDescriptor = updatedDescriptor.toDescriptor(),
+ refreshType = UpdateStatusType.None,
+ )
+ }
}
}
}
}
- }.launchIn(viewModelScope)
- getDescriptor().onEach { if (it == null) onBack() }.filterNotNull()
- .flatMapLatest { descriptor ->
+ .launchIn(viewModelScope)
+ getDescriptor()
+ .onEach { if (it == null) onBack() }
+ .filterNotNull()
+ .flatMapLatest { descriptor ->
combine(
preferenceRepository.areNetTestsEnabled(
list = descriptor.allTests.map { descriptor to it },
@@ -95,81 +103,103 @@ class DescriptorViewModel(
)
}
}
- }.launchIn(viewModelScope)
+ }
+ .launchIn(viewModelScope)
+
+ getDescriptorLastResult(descriptorKey)
+ .onEach { lastResult ->
+ _state.update {
+ it.copy(lastResult = lastResult)
+ }
+ }
+ .launchIn(viewModelScope)
+
+ events.filterIsInstance()
+ .onEach { onBack() }
+ .launchIn(viewModelScope)
+
+ events.filterIsInstance()
+ .onEach {
+ val descriptor = state.value.descriptor ?: return@onEach
+ val allTestsSelected = state.value.tests.all { it.isSelected }
+ preferenceRepository.setAreNetTestsEnabled(
+ list = descriptor.allTests.map { descriptor to it },
+ isAutoRun = true,
+ isEnabled = !allTestsSelected,
+ )
+ }
+ .launchIn(viewModelScope)
+
+ events.filterIsInstance()
+ .onEach {
+ val descriptor = state.value.descriptor ?: return@onEach
+ preferenceRepository.setAreNetTestsEnabled(
+ list = listOf(descriptor to it.test),
+ isAutoRun = true,
+ isEnabled = it.isChecked,
+ )
+ }
+ .launchIn(viewModelScope)
+
+ events.filterIsInstance()
+ .onEach {
+ deleteTestDescriptor(it.value)
+ }
+ .launchIn(viewModelScope)
- getDescriptorLastResult(descriptorKey).onEach { lastResult ->
- _state.update {
- it.copy(lastResult = lastResult)
+ events.filterIsInstance()
+ .onEach {
+ launchUrl(
+ "${OrganizationConfig.ooniRunDashboardUrl}/revisions/$descriptorKey?revision=${it.revision}",
+ )
+ }
+ .launchIn(viewModelScope)
+
+ events.filterIsInstance()
+ .onEach {
+ launchUrl(
+ "${OrganizationConfig.ooniRunDashboardUrl}/revisions/$descriptorKey",
+ )
}
- }.launchIn(viewModelScope)
-
- events.filterIsInstance().onEach { onBack() }.launchIn(viewModelScope)
-
- events.filterIsInstance().onEach {
- val descriptor = state.value.descriptor ?: return@onEach
- val allTestsSelected = state.value.tests.all { it.isSelected }
- preferenceRepository.setAreNetTestsEnabled(
- list = descriptor.allTests.map { descriptor to it },
- isAutoRun = true,
- isEnabled = !allTestsSelected,
- )
- }.launchIn(viewModelScope)
-
- events.filterIsInstance().onEach {
- val descriptor = state.value.descriptor ?: return@onEach
- preferenceRepository.setAreNetTestsEnabled(
- list = listOf(descriptor to it.test),
- isAutoRun = true,
- isEnabled = it.isChecked,
- )
- }.launchIn(viewModelScope)
-
- events.filterIsInstance().onEach {
- deleteTestDescriptor(it.value)
- }.launchIn(viewModelScope)
-
- events.filterIsInstance().onEach {
- launchUrl(
- "${OrganizationConfig.ooniRunDashboardUrl}/revisions/$descriptorKey?revision=${it.revision}",
- )
- }.launchIn(viewModelScope)
-
- events.filterIsInstance().onEach {
-
- launchUrl(
- "${OrganizationConfig.ooniRunDashboardUrl}/revisions/$descriptorKey",
- )
- }.launchIn(viewModelScope)
-
- events.filterIsInstance().onEach {
-
- val descriptor = state.value.descriptor ?: return@onEach
- if (descriptor.source !is Descriptor.Source.Installed) return@onEach
- setAutoUpdate(descriptor.source.value.id, it.value)
- }.launchIn(viewModelScope)
-
- events.filterIsInstance().onEach {
-
- if (state.value.isRefreshing) return@onEach
- val descriptor = state.value.descriptor ?: return@onEach
-
- if (descriptor.source !is Descriptor.Source.Installed) return@onEach
- _state.update {
- it.copy(refreshType = UpdateStatusType.UpdateLink, updatedDescriptor = null)
+ .launchIn(viewModelScope)
+
+ events.filterIsInstance()
+ .onEach {
+ val descriptor = state.value.descriptor ?: return@onEach
+ if (descriptor.source !is Descriptor.Source.Installed) return@onEach
+ setAutoUpdate(descriptor.source.value.id, it.value)
}
+ .launchIn(viewModelScope)
+
+ events.filterIsInstance()
+ .onEach {
+ if (state.value.isRefreshing) return@onEach
+ val descriptor = state.value.descriptor ?: return@onEach
- fetchDescriptorUpdate(listOf(descriptor.source.value))
- }.launchIn(viewModelScope)
+ if (descriptor.source !is Descriptor.Source.Installed) return@onEach
+ _state.update {
+ it.copy(refreshType = UpdateStatusType.UpdateLink, updatedDescriptor = null)
+ }
- events.filterIsInstance().onEach {
- val descriptor = state.value.updatedDescriptor ?: return@onEach
- if (descriptor.source !is Descriptor.Source.Installed) return@onEach
- _state.update {
- it.copy(refreshType = UpdateStatusType.None, updatedDescriptor = null)
+ fetchDescriptorUpdate(listOf(descriptor.source.value))
}
- reviewUpdates(listOf(descriptor.source.value))
- goToReviewDescriptorUpdates()
- }.launchIn(viewModelScope)
+ .launchIn(viewModelScope)
+
+ events.filterIsInstance()
+ .onEach {
+ val descriptor = state.value.updatedDescriptor ?: return@onEach
+ if (descriptor.source !is Descriptor.Source.Installed) return@onEach
+ _state.update {
+ it.copy(refreshType = UpdateStatusType.None, updatedDescriptor = null)
+ }
+ reviewUpdates(listOf(descriptor.source.value))
+ goToReviewDescriptorUpdates()
+ }
+ .launchIn(viewModelScope)
+
+ events.filterIsInstance()
+ .onEach { goToChooseWebsites() }
+ .launchIn(viewModelScope)
}
fun onEvent(event: Event) {
@@ -230,5 +260,7 @@ class DescriptorViewModel(
data object FetchUpdatedDescriptor : Event
data object UpdateDescriptor : Event
+
+ data object ChooseWebsitesClicked : Event
}
}
diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Navigation.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Navigation.kt
index 73a7e5703..c662e85bb 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
@@ -18,6 +18,7 @@ import org.ooni.probe.data.models.PreferenceCategoryKey
import org.ooni.probe.data.models.ResultModel
import org.ooni.probe.di.Dependencies
import org.ooni.probe.shared.decodeUrlFromBase64
+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
@@ -268,6 +269,7 @@ fun Navigation(
goToReviewDescriptorUpdates = {
navController.navigate(Screen.ReviewUpdates.route)
},
+ goToChooseWebsites = { navController.navigate(Screen.ChooseWebsites.route) },
)
}
val state by viewModel.state.collectAsState()
@@ -283,5 +285,18 @@ fun Navigation(
val state by viewModel.state.collectAsState()
ReviewUpdatesScreen(state, viewModel::onEvent)
}
+
+ composable(route = Screen.ChooseWebsites.route) { entry ->
+ val viewModel = viewModel {
+ dependencies.chooseWebsitesViewModel(
+ onBack = { navController.popBackStack() },
+ goToDashboard = {
+ navController.popBackStack(Screen.Dashboard.route, inclusive = false)
+ },
+ )
+ }
+ val state by viewModel.state.collectAsState()
+ ChooseWebsitesScreen(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 ef0a8c806..af4800beb 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
@@ -91,4 +91,6 @@ sealed class Screen(
}
data object ReviewUpdates : Screen("review-updates")
+
+ data object ChooseWebsites : Screen("choose-websites")
}
diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/UpdatesChip.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/UpdatesChip.kt
index efc6ffaa3..a8d597a90 100644
--- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/UpdatesChip.kt
+++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/UpdatesChip.kt
@@ -5,12 +5,16 @@ import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
import ooniprobe.composeapp.generated.resources.Dashboard_RunV2_UpdateTag
import ooniprobe.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.stringResource
@Composable
-fun UpdatesChip(onClick: () -> Unit) {
+fun UpdatesChip(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
SuggestionChip(
onClick = onClick,
enabled = false,
@@ -18,5 +22,6 @@ fun UpdatesChip(onClick: () -> Unit) {
labelColor = MaterialTheme.colorScheme.error,
),
label = { Text(stringResource(Res.string.Dashboard_RunV2_UpdateTag)) },
+ modifier = modifier,
)
}
diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/Validations.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/Validations.kt
new file mode 100644
index 000000000..15cc45111
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/Validations.kt
@@ -0,0 +1,5 @@
+package org.ooni.probe.ui.shared
+
+fun String.isValidUrl() =
+ Regex("^https?://[\\w-.]+?\\..{2,}")
+ .matches(this)
diff --git a/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/shared/ValidationsTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/shared/ValidationsTest.kt
new file mode 100644
index 000000000..a9acde21e
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/shared/ValidationsTest.kt
@@ -0,0 +1,20 @@
+package org.ooni.probe.ui.shared
+
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class ValidationsTest {
+ @Test
+ fun isValidUrl() {
+ assertFalse("".isValidUrl())
+ assertFalse("http://".isValidUrl())
+ assertFalse("https://".isValidUrl())
+ assertFalse("http://a".isValidUrl())
+ assertFalse("http://a.".isValidUrl())
+ assertTrue("http://example.org".isValidUrl())
+ assertTrue("http://example.co.uk".isValidUrl())
+ assertTrue("http://example.org/path".isValidUrl())
+ assertTrue("http://example.org?query=something".isValidUrl())
+ }
+}