Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 12510a55 authored by Guillaume Jacquart's avatar Guillaume Jacquart
Browse files

Merge branch 'remove_flow_mvi' into 'main'

#5444 Fix CPU consumption - remove flow-mvi dependency

See merge request !74
parents 3ca73e64 b4d35c1c
Loading
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -18,6 +18,9 @@ local.properties
/.idea/jarRepositories.xml
/.idea/google-java-format.xml
/.idea/runConfigurations.xml
/.idea/dbnavigator.xml
/.idea/deploymentTargetDropDown.xml

gradle.xml
markdown-*.xml
*.iml
+3 −181
Original line number Diff line number Diff line
@@ -29,11 +29,9 @@ In this app, we have implemented MVI using [Kotlin Flow](https://kotlinlang.org/
<img src="art/MVI-Feature.png" width="336" height="332">

Elements of a feature:
1. **Actor**: It is just a function that takes current state, user action as input and produces an effect (result) as output. This function generally makes the call to external APIs and usecases.
2. **Reducer**: It is also a very simple function whose inputs are current state, effect from the actor and it returns new state.
3. **State**: Simple POJO (kotlin data class) representing various UI states of the application.
4. **Effect**: A POJO (kotlin data class) which is returned from the actor function.
5. **SingleEventProducer**: This is a function which is invoked by the reducer to publish single events (that can/should only be consumed once like displaying toast, snackbar message or sending an analytics event). This function takes action, effect, current state as input and it returns a `SingleEvent`. By default this function is null for any Feature.
1. **Action**: The exhaustive list of user actions for a feature.
2. **State**: Simple POJO (kotlin data class) representing various UI states of the application.
3. **SingleEventProducer**: This is a function which is invoked by the reducer to publish single events (that can/should only be consumed once like displaying toast, snackbar message or sending an analytics event). This function takes action, effect, current state as input and it returns a `SingleEvent`. By default this function is null for any Feature.

### Architecture Overview of PrivacyCentral App

@@ -50,179 +48,6 @@ Looking at the diagram from right to left:
8. **ViewModel**: arch-component lifecycle aware viewmodel.
9. **Views**: Android high level components like activities, fragments, etc.

## How to implement a new feature
Imaging you have to implement a fake location feature.
1. Create a new package under `features` called `fakelocation`
2. Create a new feature class called `FakeLocationFeature` and make it extend the BaseFeature class as below:
```kotlin
class FakeLocationFeature(
    initialState: State,
    coroutineScope: CoroutineScope,
    reducer: Reducer<State, Effect>,
    actor: Actor<State, Action, Effect>,
    singleEventProducer: SingleEventProducer<State, Action, Effect, SingleEvent>
) : BaseFeature<FakeLocationFeature.State, FakeLocationFeature.Action, FakeLocationFeature.Effect, FakeLocationFeature.SingleEvent>(
    initialState,
    actor,
    reducer,
    coroutineScope,
    { message -> Log.d("FakeLocationFeature", message) },
    singleEventProducer
) {
    // Other elements goes here.
}
```

3. Define various elements for the feature in the above class
```kotlin
// State to be reflected in the UI
data class State(val location: Location)

// User triggered actions
sealed class Action {
    data class UpdateLocationAction(val latLng: LatLng) : Action()
    object UseRealLocationAction : Action()
    object UseSpecificLocationAction : Action()
    data class SetFakeLocationAction(val latitude: Double, val longitude: Double) : Action()
}

// Output from the actor after processing an action
sealed class Effect {
        data class LocationUpdatedEffect(val latitude: Double, val longitude: Double) : Effect()
        object RealLocationSelectedEffect : Effect()
        ...
        ...
        data class ErrorEffect(val message: String) : Effect()
}
```

4. Create a static `create` function in feature which returns the feature instance:
```kotlin
companion object {
        fun create(
            initialState: State = <initial state>
            coroutineScope: CoroutineScope
        ) = FakeLocationFeature(
            initialState, coroutineScope,
            reducer = { state, effect ->
                when (effect) {
                    Effect.RealLocationSelectedEffect -> state.copy(
                        location = state.location.copy(
                            mode = LocationMode.REAL_LOCATION
                        )
                    )
                    is Effect.ErrorEffect, Effect.SpecificLocationSavedEffect -> state
                    is Effect.LocationUpdatedEffect -> state.copy(
                        location = state.location.copy(
                            latitude = effect.latitude,
                            longitude = effect.longitude
                        )
                    )
                }
            },
            actor = { _, action ->
                when (action) {
                    is Action.UpdateLocationAction -> flowOf(
                        Effect.LocationUpdatedEffect(
                            action.latLng.latitude,
                            action.latLng.longitude
                        )
                    )
                    is Action.SetFakeLocationAction -> {
                        val location = Location(
                            LocationMode.CUSTOM_LOCATION,
                            action.latitude,
                            action.longitude
                        )
                        // TODO: Call fake location api with specific coordinates here.
                        val success = DummyDataSource.setLocationMode(
                            LocationMode.CUSTOM_LOCATION,
                            location
                        )
                        if (success) {
                            flowOf(
                                Effect.SpecificLocationSavedEffect
                            )
                        } else {
                            flowOf(
                                Effect.ErrorEffect("Couldn't select location")
                            )
                        }
                    }
                    Action.UseRealLocationAction -> {
                        // TODO: Call turn off fake location api here.
                        val success = DummyDataSource.setLocationMode(LocationMode.REAL_LOCATION)
                        if (success) {
                            flowOf(
                                Effect.RealLocationSelectedEffect
                            )
                        } else {
                            flowOf(
                                Effect.ErrorEffect("Couldn't select location")
                            )
                        }
                    }
                    Action.UseSpecificLocationAction -> {
                        flowOf(Effect.SpecificLocationSelectedEffect)
                    }
                }
            },
            singleEventProducer = { _, _, effect ->
                when (effect) {
                    Effect.SpecificLocationSavedEffect -> SingleEvent.SpecificLocationSavedEvent
                    Effect.RealLocationSelectedEffect -> SingleEvent.RealLocationSelectedEvent
                    is Effect.ErrorEffect -> SingleEvent.ErrorEvent(effect.message)
                    else -> null
                }
            }
        )
    }
```

5. Create a `viewmodel` like below:
```kotlin
class FakeLocationViewModel : ViewModel() {

    private val _actions = MutableSharedFlow<FakeLocationFeature.Action>()
    val actions = _actions.asSharedFlow()

    val fakeLocationFeature: FakeLocationFeature by lazy {
        FakeLocationFeature.create(coroutineScope = viewModelScope)
    }

    fun submitAction(action: FakeLocationFeature.Action) {
        viewModelScope.launch {
            _actions.emit(action)
        }
    }
}
```

6. Create a `fragment` for your feature and make sure it implements `MVIView<>` interface
7. Initialize (or retrieve the existing) instance of viewmodel in your `fragment` class by using extension function.
```kotlin
private val viewModel: FakeLocationViewModel by viewModels()
```

8. In `onCreate` method of fragment, launch a coroutine to bind the view to feature and to listen single events.
```kotlin
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    lifecycleScope.launchWhenStarted {
        viewModel.fakeLocationFeature.takeView(this, this@FakeLocationFragment)
    }
    lifecycleScope.launchWhenStarted {
        viewModel.fakeLocationFeature.singleEvents.collect { event ->
              // Do something with event
        }
    }
}
```

9. To render the state in UI, override the `render` function of MVIView.
10. For publishing ui actions, use `viewModel.submitAction(action)`.

Everything is lifecycle aware so we don't need to anything manually here.
## Code Quality and Style
This project integrates a combination of unit tests, functional test and code styling tools.
To run **unit** tests on your machine:
@@ -240,13 +65,10 @@ To run code style check and formatting tool:
The project currently doesn't have exactly the same mentioned structure as it is just a POC and will be improved.

### Todo/Improvements
- [ ] Add domain layer with usecases.
- [ ] Add data layer with repository implementation.
- [ ] Add unit tests and code coverage.
- [ ] Implement Hilt DI.

# References
1. [Kotlin Flow](https://kotlinlang.org/docs/flow.html)
2. [MVI](https://hannesdorfmann.com/android/mosby3-mvi-1/)
3. [Redux](https://redux.js.org/)
4. [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
 No newline at end of file
+26 −12
Original line number Diff line number Diff line
@@ -45,7 +45,7 @@ android {
    productFlavors {
        e29 {
            dimension 'os'
            minSdkVersion 26
            minSdkVersion 29
            targetSdkVersion 29
        }
        e30 {
@@ -103,7 +103,7 @@ android {
}

dependencies {
    implementation 'androidx.work:work-runtime-ktx:2.5.0'

    compileOnly files('libs/e-ui-sdk-1.0.1-q.jar')
    implementation files('libs/lineage-sdk.jar')
    // include the google specific version of the modules, just for the google flavor
@@ -116,21 +116,35 @@ dependencies {
    e30Implementation 'foundation.e:privacymodule.e-30:0.4.3'
    implementation 'foundation.e:privacymodule.tor:0.2.4'

    implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'


    //    implementation Libs.Kotlin.stdlib
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$Versions.kotlin"
//    implementation Libs.AndroidX.coreKtx
    implementation "androidx.core:core-ktx:1.8.0"

//    implementation Libs.AndroidX.Fragment.fragmentKtx
    implementation "androidx.fragment:fragment-ktx:$Versions.fragment"

    implementation 'androidx.appcompat:appcompat:1.4.2'
//    implementation Libs.AndroidX.Lifecycle.runtime
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:$Versions.lifecycle"
//    implementation Libs.AndroidX.Lifecycle.viewmodel
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$Versions.lifecycle"

    implementation 'androidx.work:work-runtime-ktx:2.7.1'

    implementation 'com.google.android.material:material:1.6.1'


    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-scalars:2.9.0'

    implementation project(":flow-mvi")
    implementation Libs.Kotlin.stdlib
    implementation Libs.AndroidX.coreKtx
    implementation Libs.AndroidX.Fragment.fragmentKtx
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation Libs.AndroidX.Lifecycle.runtime
    implementation Libs.AndroidX.Lifecycle.viewmodel
//    implementation Libs.MapBox.sdk
    implementation "com.mapbox.mapboxsdk:mapbox-android-sdk:$Versions.mapbox"
    implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'


    implementation Libs.MapBox.sdk
    implementation 'com.google.android.material:material:1.4.0-beta01'

    testImplementation 'junit:junit:4.+'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
+74 −30
Original line number Diff line number Diff line
@@ -20,6 +20,10 @@ package foundation.e.privacycentralapp
import android.app.Application
import android.content.Context
import android.os.Process
import androidx.lifecycle.DEFAULT_ARGS_KEY
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import foundation.e.privacycentralapp.data.repositories.AppListsRepository
import foundation.e.privacycentralapp.data.repositories.LocalStateRepository
import foundation.e.privacycentralapp.data.repositories.TrackersRepository
@@ -30,11 +34,12 @@ import foundation.e.privacycentralapp.domain.usecases.IpScramblingStateUseCase
import foundation.e.privacycentralapp.domain.usecases.TrackersStateUseCase
import foundation.e.privacycentralapp.domain.usecases.TrackersStatisticsUseCase
import foundation.e.privacycentralapp.dummy.CityDataSource
import foundation.e.privacycentralapp.features.dashboard.DashBoardViewModelFactory
import foundation.e.privacycentralapp.features.internetprivacy.InternetPrivacyViewModelFactory
import foundation.e.privacycentralapp.features.location.FakeLocationViewModelFactory
import foundation.e.privacycentralapp.features.trackers.TrackersViewModelFactory
import foundation.e.privacycentralapp.features.trackers.apptrackers.AppTrackersViewModelFactory
import foundation.e.privacycentralapp.features.dashboard.DashboardViewModel
import foundation.e.privacycentralapp.features.internetprivacy.InternetPrivacyViewModel
import foundation.e.privacycentralapp.features.location.FakeLocationViewModel
import foundation.e.privacycentralapp.features.trackers.TrackersViewModel
import foundation.e.privacycentralapp.features.trackers.apptrackers.AppTrackersFragment
import foundation.e.privacycentralapp.features.trackers.apptrackers.AppTrackersViewModel
import foundation.e.privacymodules.ipscrambler.IpScramblerModule
import foundation.e.privacymodules.ipscramblermodule.IIpScramblerModule
import foundation.e.privacymodules.location.FakeLocationModule
@@ -43,14 +48,15 @@ import foundation.e.privacymodules.permissions.PermissionsPrivacyModule
import foundation.e.privacymodules.permissions.data.ApplicationDescription
import foundation.e.privacymodules.trackers.api.BlockTrackersPrivacyModule
import foundation.e.privacymodules.trackers.api.TrackTrackersPrivacyModule
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.GlobalScope

/**
 * Simple container to hold application wide dependencies.
 *
 * TODO: Test if this implementation is leaky.
 */
@OptIn(DelicateCoroutinesApi::class)
class DependencyContainer(val app: Application) {
    val context: Context by lazy { app.applicationContext }

@@ -102,32 +108,17 @@ class DependencyContainer(val app: Application) {
        )
    }

    // ViewModelFactories
    val dashBoardViewModelFactory by lazy {
        DashBoardViewModelFactory(getQuickPrivacyStateUseCase, trackersStatisticsUseCase)
    }

    val fakeLocationViewModelFactory by lazy {
        FakeLocationViewModelFactory(
    val viewModelsFactory by lazy { ViewModelsFactory(
        getQuickPrivacyStateUseCase = getQuickPrivacyStateUseCase,
            fakeLocationStateUseCase = fakeLocationStateUseCase
        )
    }

    val internetPrivacyViewModelFactory by lazy {
        InternetPrivacyViewModelFactory(ipScramblerModule, getQuickPrivacyStateUseCase, ipScramblingStateUseCase, appListUseCase)
    }

    val trackersViewModelFactory by lazy {
        TrackersViewModelFactory(getQuickPrivacyStateUseCase, trackersStatisticsUseCase)
    }

    val appTrackersViewModelFactory by lazy {
        AppTrackersViewModelFactory(trackersStateUseCase, trackersStatisticsUseCase, getQuickPrivacyStateUseCase)
    }
        trackersStatisticsUseCase = trackersStatisticsUseCase,
        trackersStateUseCase = trackersStateUseCase,
        fakeLocationStateUseCase = fakeLocationStateUseCase,
        ipScramblerModule = ipScramblerModule,
        ipScramblingStateUseCase = ipScramblingStateUseCase,
        appListUseCase = appListUseCase
    ) }

    // Background
    @FlowPreview
    fun initBackgroundSingletons() {
        trackersStateUseCase
        ipScramblingStateUseCase
@@ -142,3 +133,56 @@ class DependencyContainer(val app: Application) {
        )
    }
}

class ViewModelsFactory(
    private val getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase,
    private val trackersStatisticsUseCase: TrackersStatisticsUseCase,
    private val trackersStateUseCase: TrackersStateUseCase,
    private val fakeLocationStateUseCase: FakeLocationStateUseCase,
    private val ipScramblerModule: IIpScramblerModule,
    private val ipScramblingStateUseCase: IpScramblingStateUseCase,
    private val appListUseCase: AppListUseCase
): ViewModelProvider.Factory {

    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
        return when (modelClass) {
            AppTrackersViewModel::class.java -> {
                val fallbackUid = android.os.Process.myPid()
                val appUid = extras[DEFAULT_ARGS_KEY]?.
                    getInt(AppTrackersFragment.PARAM_APP_UID, fallbackUid)?: fallbackUid

                AppTrackersViewModel(
                    appUid = appUid,
                    trackersStateUseCase = trackersStateUseCase,
                    trackersStatisticsUseCase = trackersStatisticsUseCase,
                    getQuickPrivacyStateUseCase = getQuickPrivacyStateUseCase
                )
            }

            TrackersViewModel::class.java ->
                TrackersViewModel(
                    getQuickPrivacyStateUseCase = getQuickPrivacyStateUseCase,
                    trackersStatisticsUseCase = trackersStatisticsUseCase
                )
            FakeLocationViewModel::class.java ->
                FakeLocationViewModel(
                    getQuickPrivacyStateUseCase = getQuickPrivacyStateUseCase,
                    fakeLocationStateUseCase = fakeLocationStateUseCase
                )
            InternetPrivacyViewModel::class.java ->
                InternetPrivacyViewModel(
                    ipScramblerModule = ipScramblerModule,
                    getQuickPrivacyStateUseCase = getQuickPrivacyStateUseCase,
                    ipScramblingStateUseCase = ipScramblingStateUseCase,
                    appListUseCase = appListUseCase
                )
            DashboardViewModel::class.java ->
                DashboardViewModel(
                    getPrivacyStateUseCase = getQuickPrivacyStateUseCase,
                    trackersStatisticsUseCase = trackersStatisticsUseCase
                )
            else -> throw IllegalArgumentException("Unknown class $modelClass")
        } as T
    }
}
+1 −1
Original line number Diff line number Diff line
@@ -26,7 +26,7 @@ class PrivacyCentralApplication : Application() {
    // Initialize the dependency container.
    val dependencyContainer: DependencyContainer by lazy { DependencyContainer(this) }

    @FlowPreview

    override fun onCreate() {
        super.onCreate()
        Mapbox.getTelemetry()?.setUserTelemetryRequestState(false)
Loading