diff --git a/composeApp/src/commonMain/kotlin/org/ooni/engine/Engine.kt b/composeApp/src/commonMain/kotlin/org/ooni/engine/Engine.kt index c4e1844b..734b38fd 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/engine/Engine.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/engine/Engine.kt @@ -2,16 +2,22 @@ package org.ooni.engine import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.ooni.engine.OonimkallBridge.SubmitMeasurementResults import org.ooni.engine.models.TaskEvent import org.ooni.engine.models.TaskEventResult +import org.ooni.engine.models.TaskLogLevel +import org.ooni.engine.models.TaskOrigin import org.ooni.engine.models.TaskSettings +import org.ooni.probe.config.Config +import org.ooni.probe.shared.Platform +import org.ooni.probe.shared.PlatformInfo class Engine( private val bridge: OonimkallBridge, @@ -19,38 +25,19 @@ class Engine( private val baseFilePath: String, private val cacheDir: String, private val taskEventMapper: TaskEventMapper, + private val networkTypeFinder: NetworkTypeFinder, + private val platformInfo: PlatformInfo, + private val backgroundDispatcher: CoroutineDispatcher, ) { - fun startTask(taskSettings: TaskSettings): Flow = + fun startTask( + name: String, + inputs: List?, + taskOrigin: TaskOrigin, + ): Flow = channelFlow { - val finalSettings = - taskSettings.copy( - stateDir = "$baseFilePath/state", - tunnelDir = "$baseFilePath/tunnel", - tempDir = cacheDir, - assetsDir = "$baseFilePath/assets", - ) - - val response = httpDo(finalSettings) - response?.let { - Logger.d(it) - } - - val checkinResults = checkIn(finalSettings) - - val task = - bridge.startTask( - json.encodeToString( - checkinResults?.urls?.map { it.url }?.let { - finalSettings.copy( - inputs = it.take(1), - options = - finalSettings.options.copy( - maxRuntime = 90, - ), - ) - } ?: finalSettings, - ), - ) + val taskSettings = buildTaskSettings(name, inputs, taskOrigin) + val settingsSerialized = json.encodeToString(taskSettings) + val task = bridge.startTask(settingsSerialized) while (!task.isDone()) { val eventJson = task.waitForNextEvent() @@ -63,62 +50,134 @@ class Engine( task.interrupt() } } - } - - fun session(finalSettings: TaskSettings): OonimkallBridge.Session { - return bridge.newSession( - OonimkallBridge.SessionConfig( - softwareName = finalSettings.options.softwareName, - softwareVersion = finalSettings.options.softwareVersion, - proxy = null, - probeServicesURL = "https://api.prod.ooni.io", - assetsDir = finalSettings.assetsDir.toString(), - stateDir = finalSettings.stateDir.toString(), - tempDir = finalSettings.tempDir.toString(), - tunnelDir = finalSettings.tunnelDir.toString(), - logger = - object : OonimkallBridge.Logger { - override fun debug(msg: String?) { - msg?.let { Logger.d(it) } - } + }.flowOn(backgroundDispatcher) - override fun info(msg: String?) { - msg?.let { Logger.d(it) } - } - - override fun warn(msg: String?) { - msg?.let { Logger.d(it) } - } - }, - verbose = true, - ), - ) - } + suspend fun submitMeasurements( + measurement: String, + taskOrigin: TaskOrigin = TaskOrigin.OoniRun, + ): SubmitMeasurementResults = + withContext(backgroundDispatcher) { + val sessionConfig = buildSessionConfig(taskOrigin) + session(sessionConfig).submitMeasurement(measurement) + } - suspend fun checkIn(finalSettings: TaskSettings): OonimkallBridge.CheckInResults? { - return withContext(Dispatchers.IO) { - return@withContext session(finalSettings).checkIn( + suspend fun checkIn( + categories: List, + taskOrigin: TaskOrigin, + ): OonimkallBridge.CheckInResults = + withContext(backgroundDispatcher) { + val sessionConfig = buildSessionConfig(taskOrigin) + session(sessionConfig).checkIn( OonimkallBridge.CheckInConfig( charging = true, onWiFi = true, - platform = "android", - runType = "autorun", - softwareName = "ooniprobe-android-unattended", - softwareVersion = "3.8.8", - webConnectivityCategories = listOf("NEWS"), + platform = platformInfo.platform.value, + runType = taskOrigin.value, + softwareName = sessionConfig.softwareName, + softwareVersion = sessionConfig.softwareVersion, + webConnectivityCategories = categories, ), ) } - } - suspend fun httpDo(finalSettings: TaskSettings): String? { - return withContext(Dispatchers.IO) { - return@withContext session(finalSettings).httpDo( + suspend fun httpDo( + method: String, + url: String, + taskOrigin: TaskOrigin = TaskOrigin.OoniRun, + ): String? = + withContext(backgroundDispatcher) { + session(buildSessionConfig(taskOrigin)).httpDo( OonimkallBridge.HTTPRequest( - url = "https://api.dev.ooni.io/api/v2/oonirun/links/10426", - method = "GET", + method = method, + url = url, ), ).body } + + private fun session(sessionConfig: OonimkallBridge.SessionConfig): OonimkallBridge.Session { + return bridge.newSession(sessionConfig) + } + + private fun buildTaskSettings( + name: String, + inputs: List?, + taskOrigin: TaskOrigin, + ) = TaskSettings( + name = name, + inputs = inputs.orEmpty(), + disabledEvents = + listOf( + "status.queued", + "status.update.websites", + "failure.report_close", + ), + // TODO: fetch from preferences + logLevel = TaskLogLevel.Info, + stateDir = "$baseFilePath/state", + tunnelDir = "$baseFilePath/tunnel", + tempDir = cacheDir, + assetsDir = "$baseFilePath/assets", + options = + TaskSettings.Options( + // TODO: fetch from preferences + noCollector = true, + softwareName = buildSoftwareName(taskOrigin), + softwareVersion = platformInfo.version, + // TODO: fetch from preferences + maxRuntime = -1, + ), + annotations = + TaskSettings.Annotations( + networkType = networkTypeFinder(), + flavor = buildSoftwareName(taskOrigin), + origin = taskOrigin, + ), + // TODO: fetch from preferences + proxy = null, + ) + + private fun buildSessionConfig(taskOrigin: TaskOrigin) = + OonimkallBridge.SessionConfig( + softwareName = buildSoftwareName(taskOrigin), + softwareVersion = platformInfo.version, + // TODO: fetch from preferences + proxy = null, + probeServicesURL = Config.OONI_API_BASE_URL, + stateDir = "$baseFilePath/state", + tunnelDir = "$baseFilePath/tunnel", + tempDir = cacheDir, + assetsDir = "$baseFilePath/assets", + logger = oonimkallLogger, + verbose = false, + ) + + private fun buildSoftwareName(taskOrigin: TaskOrigin) = + Config.BASE_SOFTWARE_NAME + + "-" + + platformInfo.platform.value + + "-" + + (if (taskOrigin == TaskOrigin.AutoRun) "-" + "unattended" else "") + + private val Platform.value + get() = + when (this) { + Platform.Android -> "android" + Platform.Ios -> "ios" + } + + private val oonimkallLogger by lazy { + object : OonimkallBridge.Logger { + override fun debug(msg: String?) { + msg?.let { Logger.d(it) } + } + + override fun info(msg: String?) { + msg?.let { Logger.d(it) } + } + + override fun warn(msg: String?) { + msg?.let { Logger.d(it) } + } + } } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/engine/models/TaskSettings.kt b/composeApp/src/commonMain/kotlin/org/ooni/engine/models/TaskSettings.kt index b84cb982..2f88aff5 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/engine/models/TaskSettings.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/engine/models/TaskSettings.kt @@ -10,15 +10,15 @@ data class TaskSettings( @SerialName("inputs") val inputs: List, @SerialName("version") val version: Int = 1, @SerialName("log_level") val logLevel: TaskLogLevel, - @SerialName("disabled_events") val disabledEvents: List = emptyList(), + @SerialName("disabled_events") val disabledEvents: List, + @SerialName("state_dir") val stateDir: String, + @SerialName("temp_dir") val tempDir: String, + @SerialName("tunnel_dir") val tunnelDir: String, + @SerialName("assets_dir") val assetsDir: String, @SerialName("options") val options: Options, @SerialName("annotations") val annotations: Annotations, // Optional - @SerialName("proxy") val proxy: String? = null, - @SerialName("state_dir") val stateDir: String? = null, - @SerialName("temp_dir") val tempDir: String? = null, - @SerialName("tunnel_dir") val tunnelDir: String? = null, - @SerialName("assets_dir") val assetsDir: String? = null, + @SerialName("proxy") val proxy: String?, ) { @Serializable data class Options( @@ -27,7 +27,7 @@ data class TaskSettings( // built from the flavors + debug or not + -unattended if autorun @SerialName("software_name") val softwareName: String, @SerialName("software_version") val softwareVersion: String, - @SerialName("max_runtime") val maxRuntime: Int? = null, + @SerialName("max_runtime") val maxRuntime: Int, ) @Serializable 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 ddabea7a..2fef0c8e 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -1,6 +1,8 @@ package org.ooni.probe.di import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.serialization.json.Json import org.ooni.engine.Engine import org.ooni.engine.NetworkTypeFinder @@ -19,6 +21,10 @@ class Dependencies( private val baseFileDir: String, private val cacheDir: String, ) { + // Commong + + private val backgroundDispatcher = Dispatchers.IO + // Data private val json by lazy { buildJson() } @@ -26,8 +32,21 @@ class Dependencies( // Engine private val networkTypeFinder by lazy { NetworkTypeFinder { NetworkType.Unknown("") } } // TODO + private val taskEventMapper by lazy { TaskEventMapper(networkTypeFinder, json) } - private val engine by lazy { Engine(oonimkallBridge, json, baseFileDir, cacheDir, taskEventMapper) } + + private val engine by lazy { + Engine( + bridge = oonimkallBridge, + json = json, + baseFilePath = baseFileDir, + cacheDir = cacheDir, + taskEventMapper = taskEventMapper, + networkTypeFinder = networkTypeFinder, + platformInfo = platformInfo, + backgroundDispatcher = backgroundDispatcher, + ) + } // ViewModels diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardViewModel.kt index 42d7bf6c..5f3537bf 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardViewModel.kt @@ -2,24 +2,19 @@ package org.ooni.probe.ui.dashboard import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO +import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import org.ooni.engine.Engine -import org.ooni.engine.models.NetworkType -import org.ooni.engine.models.TaskLogLevel +import org.ooni.engine.models.TaskEvent import org.ooni.engine.models.TaskOrigin -import org.ooni.engine.models.TaskSettings -import org.ooni.probe.config.Config class DashboardViewModel( private val engine: Engine, @@ -38,9 +33,30 @@ class DashboardViewModel( _state.value = _state.value.copy(isRunning = true) - engine.startTask(TASK_SETTINGS) + val response = + engine.httpDo( + method = "GET", + url = "https://api.dev.ooni.io/api/v2/oonirun/links/10426", + ) + response?.let { Logger.d(it) } + + val checkInResults = + engine.checkIn( + categories = listOf("NEWS"), + taskOrigin = TaskOrigin.OoniRun, + ) + + engine.startTask( + name = "web_connectivity", + inputs = checkInResults.urls.map { it.url }, + taskOrigin = TaskOrigin.OoniRun, + ) .onEach { taskEvent -> _state.update { state -> + // Can't print the Measurement event, + // it's too long and halts the main thread + if (taskEvent is TaskEvent.Measurement) return@update state + state.copy(log = state.log + "\n" + taskEvent) } } @@ -50,12 +66,6 @@ class DashboardViewModel( } } } - /* - This is only needed for this example. The best practice is for the data layer to - switch to a background dispatcher whenever is needed, and the viewModel should run - on the default (Main) dispatcher. - */ - .flowOn(Dispatchers.IO) .launchIn(viewModelScope) } @@ -71,25 +81,4 @@ class DashboardViewModel( sealed interface Event { data object StartClick : Event } - - companion object { - val TASK_SETTINGS = - TaskSettings( - name = "web_connectivity", - inputs = listOf("https://ooni.org"), - logLevel = TaskLogLevel.Info, - options = - TaskSettings.Options( - noCollector = true, - softwareName = Config.BASE_SOFTWARE_NAME, - softwareVersion = "1.0", - ), - annotations = - TaskSettings.Annotations( - networkType = NetworkType.Wifi, - flavor = Config.BASE_SOFTWARE_NAME, - origin = TaskOrigin.OoniRun, - ), - ) - } } diff --git a/composeApp/src/commonTest/kotlin/org/ooni/engine/EngineTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/engine/EngineTest.kt new file mode 100644 index 00000000..c347403e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/ooni/engine/EngineTest.kt @@ -0,0 +1,60 @@ +package org.ooni.engine + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.ooni.engine.models.NetworkType +import org.ooni.engine.models.TaskEvent +import org.ooni.engine.models.TaskOrigin +import org.ooni.engine.models.TaskSettings +import org.ooni.probe.di.Dependencies +import org.ooni.probe.shared.Platform +import org.ooni.probe.shared.PlatformInfo +import kotlin.test.Test +import kotlin.test.assertEquals + +class EngineTest { + private val json = Dependencies.buildJson() + private val networkTypeFinder = NetworkTypeFinder { NetworkType.NoInternet } + + @Test + fun startTaskAndGetEvents() = + runTest { + val bridge = TestOonimkallBridge() + bridge.addNextEvents("""{"key":"status.started","value":{}}""") + val engine = buildEngine(bridge) + + val events = + engine.startTask( + name = "web_connectivity", + inputs = listOf("https://ooni.org"), + TaskOrigin.OoniRun, + ).toList() + + assertEquals(1, events.size) + assertEquals(TaskEvent.Started, events.first()) + + val settings = json.decodeFromString(bridge.lastStartTaskSettingsSerialized!!) + assertEquals("web_connectivity", settings.name) + assertEquals(listOf("https://ooni.org"), settings.inputs) + assertEquals(NetworkType.NoInternet, settings.annotations.networkType) + } + + private fun buildEngine(bridge: OonimkallBridge) = + Engine( + bridge = bridge, + json = json, + baseFilePath = "", + cacheDir = "", + taskEventMapper = TaskEventMapper(networkTypeFinder, json), + networkTypeFinder = networkTypeFinder, + platformInfo = + object : PlatformInfo { + override val version = "1" + override val platform = Platform.Ios + override val osVersion = "1" + override val model = "test" + }, + backgroundDispatcher = Dispatchers.Default, + ) +} diff --git a/composeApp/src/commonTest/kotlin/org/ooni/engine/TestOonimkallBridge.kt b/composeApp/src/commonTest/kotlin/org/ooni/engine/TestOonimkallBridge.kt new file mode 100644 index 00000000..746e8cf2 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/ooni/engine/TestOonimkallBridge.kt @@ -0,0 +1,58 @@ +package org.ooni.engine + +/* + Bridge implementation to be used in tests + */ +class TestOonimkallBridge : OonimkallBridge { + // Test helpers + + private val nextEvents = mutableListOf() + + fun addNextEvents(vararg events: String) { + nextEvents.addAll(events) + } + + var lastStartTaskSettingsSerialized: String? = null + private set + + var lastSessionConfig: OonimkallBridge.SessionConfig? = null + private set + + var submitMeasurement: ((String) -> OonimkallBridge.SubmitMeasurementResults)? = null + var checkIn: ((OonimkallBridge.CheckInConfig) -> OonimkallBridge.CheckInResults)? = null + var httpDo: ((OonimkallBridge.HTTPRequest) -> OonimkallBridge.HTTPResponse)? = null + + // Base implementation + + override fun startTask(settingsSerialized: String): OonimkallBridge.Task { + lastStartTaskSettingsSerialized = settingsSerialized + return object : OonimkallBridge.Task { + override fun interrupt() {} + + override fun isDone() = nextEvents.isEmpty() + + override fun waitForNextEvent(): String { + return nextEvents.removeAt(0) + } + } + } + + override fun newSession(sessionConfig: OonimkallBridge.SessionConfig): OonimkallBridge.Session { + lastSessionConfig = sessionConfig + return Session() + } + + class Session : OonimkallBridge.Session { + override fun submitMeasurement(measurement: String): OonimkallBridge.SubmitMeasurementResults { + return submitMeasurement(measurement) + } + + override fun checkIn(config: OonimkallBridge.CheckInConfig): OonimkallBridge.CheckInResults { + return checkIn(config) + } + + override fun httpDo(request: OonimkallBridge.HTTPRequest): OonimkallBridge.HTTPResponse { + return httpDo(request) + } + } +}