Skip to content

Commit

Permalink
DI + Navigation + ViewModel
Browse files Browse the repository at this point in the history
  • Loading branch information
sdsantos committed Jul 29, 2024
1 parent e93fcca commit b7300a9
Show file tree
Hide file tree
Showing 15 changed files with 219 additions and 150 deletions.
83 changes: 25 additions & 58 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,75 +1,42 @@
# OONI Probe Multiplatform

The goal of this repo is to outline a proposed architecture for building a cross platform app that
targets Android, iOS and Desktop (windows and macOS).
Multiplatform (Android and iOS currently) version of the Probe app.

The idea is not to use this project as-is, but rather use it as a reference and playground to
experiment with design pattern related to iteratively refactoring OONI Probe Android, iOS and Desktop
under a unified code base.
## Project structure


### Project structure

* `/composeApp` is for code that will be shared across your Compose Multiplatform applications.
* `composeApp` is for code that will be shared across your Compose Multiplatform applications.
It contains several subfolders:
- `commonMain` is for code that’s common for all targets.
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name.
For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app,
`iosMain` would be the right folder for such calls.

* `/iosApp` contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform,
you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.

### Architecture overview

To best understand the architecture it's best you look at the commit history:

#### kmp boilerplate
commit: https://github.com/ooni/probe-multiplatform/commit/e8f2f6dc4f09f15679e064e5350be257e5de9335

nothing really to see here, this is just the output, as-is of
https://kmp.jetbrains.com/:

### general app architecture
- `androidMain` Android-specific code
- `iosMain` iOS-specific code written in Kotlin

commit: https://github.com/ooni/probe-multiplatform/commit/917e92c4689e6ee664a36a7b9266d56422257e1b
* `iosApp` contains the iOS application configuration and the engine integration written in Swift

this is where all the setup of the architecture of the app is done to create a
structure that should be relatively scalable and modular to support the
specific cross platform constraints we have in our app
* `.github` contains the Continuous Integration configuration for Github

### golang bridging
commit: https://github.com/ooni/probe-multiplatform/commit/38f95f35223808f3d531a4690458776645c02105
* `gradle/libs.versions.toml` specifies the versions of the plugins and dependencies used across
the different modules.

this is where the golang bridging actually happens.
## Architecture overview

The build of the library is actually done inside of gradle steps using a combination of cmake + Makefiles as part of building the app.
Our aim is to take advantage of multiplatform features as much as possible, specially [Compose
Multiplatform](https://www.jetbrains.com/lp/compose-multiplatform/). Platform specific modules
will be limited to the minimum required to setup and launch the apps with a compose wrapper,
besides platform-specific code that we can’t avoid, such as the loading our pre-compiled engine.

The relevant bits to do this are here:
* main gradle entry point: https://github.com/ooni/probe-multiplatform/commit/38f95f35223808f3d531a4690458776645c02105#diff-9ea83bf74425e7270f5dd[%E2%80%A6]feb671f9578fabdec009eb4ba4a
* cmake config references from gradle: https://github.com/ooni/probe-multiplatform/commit/38f95f35223808f3d531a4690458776645c02105#diff-ee1cbd25a6321e45f790ea552825ea601d5ac9a6233aaba6e3e71143957985cd
* Makefile doing the actual build: https://github.com/ooni/probe-multiplatform/commit/38f95f35223808f3d531a4690458776645c02105#diff-ac4f5f32e2945fdd5a37c125502d77bb5ccdebc1d9797469916295930d587f01
### Principals

The JNI is built by hand instead of relying on gomobile. The reason for this is
that gomobile doesn't actually work that well (it doesn't support complex
types), so instead we build a very minimal bridge API surface that can then be
mapped to correct types directly inside of kotlin.
* [Dependency Inversion](https://developer.android.com/topic/modularization/patterns#dependency_inversion)
* [Unidirectional Data Flow](https://developer.android.com/develop/ui/compose/architecture#udf)
* [Model-View-ViewModel](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-viewmodel.html)

* Specifically the mobile API has only 2 functions: apiCall and apiCallWithArgs: https://github.com/ooni/probe-multiplatform/commit/38f95f35223808f3d531a4690458776645c02105#diff-a18d06043032f63c9ef45e5de6fd5a014d533786993260306511f2fe0135f070R134
* These two functions are mapped using the JNI and linked into the native GoOONIProbeClient: https://github.com/ooni/probe-multiplatform/commit/38f95f35223808f3d531a4690458776645c02105#diff-68b2196fd77caf703a289903ce6a1b5d03a167c774d5a6150fb4c9bf734c0228
* There is then a bridge which actually instantiates the OONIProbeClient and calls the static methods on top of it: https://github.com/ooni/probe-multiplatform/commit/38f95f35223808f3d531a4690458776645c02105#diff-0160d602dbe85af66fc75f7f96003f6cd0129f965a6a39636117702deebc1ce5
- The reason to use a bridge instead of calling OONIProbeClient directly is that we need to be able to inject dependencies at runtime to swap out the native implementation for each platform
* What a mobile app developers ends up using, in the end, is the nicer typed
interface which uses the bridge to call the native functions and handles the serialization/deserialization of function call arguments and return values from the native calls: https://github.com/ooni/probe-multiplatform/commit/38f95f35223808f3d531a4690458776645c02105#diff-799618a943c0407c70266082ddf3882bd252160bbc83a135d999df074d4109d9
### Main module structure

What will eventually be calling probe-cli under the hood would live in here:
https://github.com/ooni/probe-multiplatform/commit/38f95f35223808f3d531a4690458776645c02105#diff-a18d06043032f63c9ef45e5de6fd5a014d533786993260306511f2fe0135f070
* `engine` the Oonimkall engine abstraction in kotlin

What is inside of the BEGIN API section should all be replaced with just an
import from `github.com/ooni/probe-engine` which should export a type API
struct which lives somewhere inside of a `mobileapi` package that implements
the `Call`, `CallWithArgs` and `Init` methods.
* `probe` our Probe app code

The shim code on the other hand can and probably should live directly inside of
the probe codebase so that it's as easy to change as possible and understand
the bridging layer properly.
* `di` dependency injection
* `shared` classes and methods shared across the whole app
* `data` data layer code (database, preferences, network...)
* `ui` UI layer code, organized into features/screens
8 changes: 4 additions & 4 deletions composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,18 @@ kotlin {

sourceSets {
androidMain.dependencies {
implementation(libs.compose.ui.tooling.preview)
implementation(libs.androidx.activity.compose)
implementation(compose.preview)
implementation(libs.android.oonimkall)
}
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation(libs.kotlin.serialization)
implementation(libs.bundles.ui)
implementation(libs.bundles.tooling)
}

Expand Down Expand Up @@ -98,13 +97,14 @@ android {
}
buildFeatures {
buildConfig = true
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
dependencies {
debugImplementation(libs.compose.ui.tooling)
debugImplementation(compose.uiTooling)
}
android {
lint {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
package org.ooni.probe

import android.app.Application
import android.os.Build
import org.ooni.engine.AndroidOonimkallBridge
import org.ooni.probe.di.Dependencies
import org.ooni.probe.shared.Platform
import org.ooni.probe.shared.PlatformInfo

class AndroidApplication : Application()
class AndroidApplication : Application() {
val dependencies by lazy {
Dependencies(
platformInfo = platformInfo,
oonimkallBridge = AndroidOonimkallBridge(),
baseFileDir = filesDir.absolutePath,
)
}

private val platformInfo by lazy {
object : PlatformInfo {
override val version = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
override val platform = Platform.Android
override val osVersion = Build.VERSION.SDK_INT.toString()
override val model = "${Build.MANUFACTURER} ${Build.MODEL}"
}
}
}
28 changes: 3 additions & 25 deletions composeApp/src/androidMain/kotlin/org/ooni/probe/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,38 +1,16 @@
package org.ooni.probe

import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import org.ooni.engine.AndroidOonimkallBridge
import org.ooni.probe.di.Dependencies
import org.ooni.probe.shared.Platform
import org.ooni.probe.shared.PlatformInfo

class MainActivity : ComponentActivity() {
private val app get() = applicationContext as AndroidApplication

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

setContent {
App(setupDependencies())
App(app.dependencies)
}
}

private fun setupDependencies(): Dependencies {
val platformInfo =
object : PlatformInfo {
override val version = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
override val platform = Platform.Android
override val osVersion = Build.VERSION.SDK_INT.toString()
override val model = "${Build.MANUFACTURER} ${Build.MODEL}"
}
val bridge = AndroidOonimkallBridge()
val dependencies =
Dependencies(
platformInfo = platformInfo,
oonimkallBridge = bridge,
baseFileDir = filesDir.absolutePath,
)
return dependencies
}
}
21 changes: 14 additions & 7 deletions composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,40 @@ package org.ooni.probe

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.navigation.compose.rememberNavController
import co.touchlab.kermit.Logger
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.ooni.probe.di.Dependencies
import org.ooni.probe.ui.Theme
import org.ooni.probe.ui.main.MainScreen
import org.ooni.probe.ui.navigation.Navigation

@Composable
@Preview
fun App(dependencies: Dependencies) {
LaunchedEffect(Unit) {
logAppStart(dependencies)
}
val navController = rememberNavController()

Theme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
MainScreen(
dependencies.mainViewModel,
)
Scaffold {
Navigation(
navController = navController,
dependencies = dependencies,
)
}
}
}

LaunchedEffect(Unit) {
logAppStart(dependencies)
}
}

private fun logAppStart(dependencies: Dependencies) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,24 @@ import kotlinx.serialization.json.Json
import org.ooni.engine.Engine
import org.ooni.engine.OonimkallBridge
import org.ooni.probe.shared.PlatformInfo
import org.ooni.probe.ui.main.MainViewModel
import org.ooni.probe.ui.dashboard.DashboardViewModel

class Dependencies(
val platformInfo: PlatformInfo,
private val oonimkallBridge: OonimkallBridge,
private val baseFileDir: String,
) {
// Data
private val json by lazy {
Json {
encodeDefaults = true
ignoreUnknownKeys = true
}
}

// Engine
private val engine by lazy { Engine(oonimkallBridge, json, baseFileDir) }

val mainViewModel by lazy { MainViewModel(engine) }
// ViewModels
val dashboardViewModel get() = DashboardViewModel(engine)
}
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
package org.ooni.probe.ui.main
package org.ooni.probe.ui.dashboard

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.ooni.probe.ui.Theme

@Composable
fun MainScreen(viewModel: MainViewModel) {
val state by viewModel.state.collectAsState()

fun DashboardScreen(
state: DashboardViewModel.State,
onEvent: (DashboardViewModel.Event) -> Unit,
) {
Column {
Button(
onClick = { viewModel.onEvent(MainViewModel.Event.StartClick) },
onClick = { onEvent(DashboardViewModel.Event.StartClick) },
enabled = !state.isRunning,
) {
Text("Run Test")
Expand All @@ -32,3 +33,14 @@ fun MainScreen(viewModel: MainViewModel) {
)
}
}

@Preview
@Composable
fun DashboardScreenPreview() {
Theme {
DashboardScreen(
state = DashboardViewModel.State(isRunning = false, log = ""),
onEvent = {},
)
}
}
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
package org.ooni.probe.ui.main
package org.ooni.probe.ui.dashboard

import kotlinx.coroutines.CoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
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.TaskSettings

class MainViewModel(
class DashboardViewModel(
private val engine: Engine,
) {
) : ViewModel() {
private val events = MutableSharedFlow<Event>(extraBufferCapacity = 1)

private val _state = MutableStateFlow(State())
Expand All @@ -44,7 +46,13 @@ class MainViewModel(
}
}
}
.launchIn(CoroutineScope(Dispatchers.IO))
/*
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)
}

fun onEvent(event: Event) {
Expand Down
Loading

0 comments on commit b7300a9

Please sign in to comment.