diff options
author | Guillaume Jacquart <guillaume.jacquart@hoodbrains.com> | 2022-07-28 06:04:25 +0000 |
---|---|---|
committer | Guillaume Jacquart <guillaume.jacquart@hoodbrains.com> | 2022-07-28 06:04:25 +0000 |
commit | 12510a55c9c2b1d21c6e1f45d0058778ddfc9eaa (patch) | |
tree | f87e29f670323b7173e5e3875112271c8835a5d3 | |
parent | 3ca73e64ddd25c7c20eca2e4e0db77032db848c0 (diff) | |
parent | b4d35c1c12120503e74d7ae99edd94302673acf6 (diff) |
Merge branch 'remove_flow_mvi' into 'main'
#5444 Fix CPU consumption - remove flow-mvi dependency
See merge request e/os/advanced-privacy!74
49 files changed, 1089 insertions, 2054 deletions
@@ -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 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 75e1535..2743aac 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 diff --git a/app/build.gradle b/app/build.gradle index 23d6ecd..5f2b302 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/java/foundation/e/privacycentralapp/DependencyContainer.kt b/app/src/main/java/foundation/e/privacycentralapp/DependencyContainer.kt index 727d00d..6be3724 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/DependencyContainer.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/DependencyContainer.kt @@ -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( - 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) - } + val viewModelsFactory by lazy { ViewModelsFactory( + getQuickPrivacyStateUseCase = 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 + } +} diff --git a/app/src/main/java/foundation/e/privacycentralapp/PrivacyCentralApplication.kt b/app/src/main/java/foundation/e/privacycentralapp/PrivacyCentralApplication.kt index 2d90c93..b23be3d 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/PrivacyCentralApplication.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/PrivacyCentralApplication.kt @@ -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) diff --git a/app/src/main/java/foundation/e/privacycentralapp/common/GraphHolder.kt b/app/src/main/java/foundation/e/privacycentralapp/common/GraphHolder.kt index 32766ca..d7a9dd0 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/common/GraphHolder.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/common/GraphHolder.kt @@ -40,7 +40,7 @@ import com.github.mikephil.charting.highlight.Highlight import com.github.mikephil.charting.listener.OnChartValueSelectedListener import com.github.mikephil.charting.utils.MPPointF import foundation.e.privacycentralapp.R -import foundation.e.privacycentralapp.extensions.dpToPxF +import foundation.e.privacycentralapp.common.extensions.dpToPxF class GraphHolder(val barChart: BarChart, val context: Context, val isMarkerAbove: Boolean = true) { var data = emptyList<Pair<Int, Int>>() diff --git a/flow-mvi/src/main/java/foundation/e/flowmvi/MVIView.kt b/app/src/main/java/foundation/e/privacycentralapp/common/ThrottleFlow.kt index aa6f624..21e1542 100644 --- a/flow-mvi/src/main/java/foundation/e/flowmvi/MVIView.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/common/ThrottleFlow.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 E FOUNDATION + * Copyright (C) 2022 E FOUNDATION * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -15,13 +15,22 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -package foundation.e.flowmvi +package foundation.e.privacycentralapp.common +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlin.time.Duration -interface MVIView<State, out Action> { - - fun render(state: State) - - fun actions(): Flow<Action> +@FlowPreview +fun <T> Flow<T>.throttleFirst(windowDuration: Duration): Flow<T> = flow { + var lastEmissionTime = 0L + collect { upstream -> + val currentTime = System.currentTimeMillis() + val mayEmit = currentTime - lastEmissionTime > windowDuration.inWholeMilliseconds + if (mayEmit) { + lastEmissionTime = currentTime + emit(upstream) + } + } } diff --git a/app/src/main/java/foundation/e/privacycentralapp/extensions/AnyExtension.kt b/app/src/main/java/foundation/e/privacycentralapp/common/extensions/AnyExtension.kt index 2074b69..5c73df9 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/extensions/AnyExtension.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/common/extensions/AnyExtension.kt @@ -15,7 +15,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -package foundation.e.privacycentralapp.extensions +package foundation.e.privacycentralapp.common.extensions import android.content.Context diff --git a/app/src/main/java/foundation/e/privacycentralapp/data/repositories/LocalStateRepository.kt b/app/src/main/java/foundation/e/privacycentralapp/data/repositories/LocalStateRepository.kt index b4bca0b..af8646a 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/data/repositories/LocalStateRepository.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/data/repositories/LocalStateRepository.kt @@ -48,7 +48,7 @@ class LocalStateRepository(context: Context) { return isFirstActivation } - var quickPrivacyEnabledFlow: Flow<Boolean> = quickPrivacyEnabledMutableFlow + var quickPrivacyEnabledFlow: StateFlow<Boolean> = quickPrivacyEnabledMutableFlow val areAllTrackersBlocked: MutableStateFlow<Boolean> = MutableStateFlow(false) diff --git a/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/FakeLocationStateUseCase.kt b/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/FakeLocationStateUseCase.kt index 5446d3b..aa4276d 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/FakeLocationStateUseCase.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/FakeLocationStateUseCase.kt @@ -35,7 +35,6 @@ import foundation.e.privacymodules.permissions.data.ApplicationDescription import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlin.random.Random @@ -144,11 +143,12 @@ class FakeLocationStateUseCase( // Deprecated since API 29, never called. override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} - override fun onProviderEnabled(provider: String?) { + // TODO migration to minSdk31 , check still working. + override fun onProviderEnabled(provider: String) { reset(provider) } - override fun onProviderDisabled(provider: String?) { + override fun onProviderDisabled(provider: String) { reset(provider) } diff --git a/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/GetQuickPrivacyStateUseCase.kt b/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/GetQuickPrivacyStateUseCase.kt index 36599cb..7377568 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/GetQuickPrivacyStateUseCase.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/GetQuickPrivacyStateUseCase.kt @@ -17,11 +17,13 @@ package foundation.e.privacycentralapp.domain.usecases +import android.util.Log import foundation.e.privacycentralapp.data.repositories.LocalStateRepository import foundation.e.privacycentralapp.domain.entities.InternetPrivacyMode import foundation.e.privacycentralapp.domain.entities.LocationMode import foundation.e.privacycentralapp.domain.entities.QuickPrivacyState import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine @@ -29,7 +31,7 @@ import kotlinx.coroutines.launch class GetQuickPrivacyStateUseCase( private val localStateRepository: LocalStateRepository, - private val coroutineScope: CoroutineScope + coroutineScope: CoroutineScope ) { init { @@ -40,9 +42,11 @@ class GetQuickPrivacyStateUseCase( } } - val quickPrivacyEnabledFlow = localStateRepository.quickPrivacyEnabledFlow + val quickPrivacyEnabledFlow: Flow<Boolean> = localStateRepository.quickPrivacyEnabledFlow - val quickPrivacyState = combine( + val isQuickPrivacyEnabled: Boolean get() = localStateRepository.isQuickPrivacyEnabled + + val quickPrivacyState: Flow<QuickPrivacyState> = combine( localStateRepository.quickPrivacyEnabledFlow, localStateRepository.areAllTrackersBlocked, localStateRepository.locationMode, @@ -60,14 +64,14 @@ class GetQuickPrivacyStateUseCase( } } - val isTrackersDenied = combine( + val isTrackersDenied: Flow<Boolean> = combine( localStateRepository.quickPrivacyEnabledFlow, localStateRepository.areAllTrackersBlocked ) { isQuickPrivacyEnabled, isAllTrackersBlocked -> isQuickPrivacyEnabled && isAllTrackersBlocked } - val isLocationHidden = combine( + val isLocationHidden: Flow<Boolean> = combine( localStateRepository.quickPrivacyEnabledFlow, localStateRepository.locationMode ) { isQuickPrivacyEnabled, locationMode -> @@ -76,7 +80,7 @@ class GetQuickPrivacyStateUseCase( val locationMode: StateFlow<LocationMode> = localStateRepository.locationMode - val isIpHidden = combine( + val isIpHidden: Flow<Boolean?> = combine( localStateRepository.quickPrivacyEnabledFlow, localStateRepository.internetPrivacyMode ) { isQuickPrivacyEnabled, internetPrivacyMode -> diff --git a/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/IpScramblingStateUseCase.kt b/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/IpScramblingStateUseCase.kt index 0d25d16..c7c434c 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/IpScramblingStateUseCase.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/IpScramblingStateUseCase.kt @@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -48,7 +47,7 @@ class IpScramblingStateUseCase( val internetPrivacyMode: StateFlow<InternetPrivacyMode> = callbackFlow { val listener = object : IIpScramblerModule.Listener { override fun onStatusChanged(newStatus: IIpScramblerModule.Status) { - offer(map(newStatus)) + trySend(map(newStatus)) } override fun log(message: String) {} diff --git a/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/TrackersStateUseCase.kt b/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/TrackersStateUseCase.kt index 3319eb0..6417fce 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/TrackersStateUseCase.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/TrackersStateUseCase.kt @@ -61,6 +61,10 @@ class TrackersStateUseCase( return appListsRepository.getApplicationDescription(packageName) } + fun getApplicationDescription(appUid: Int): ApplicationDescription? { + return appListsRepository.getApplicationDescription(appUid) + } + fun isWhitelisted(appUid: Int): Boolean { return if (appUid == appListsRepository.dummySystemApp.uid) { appListsRepository.getHiddenSystemApps().any { diff --git a/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/TrackersStatisticsUseCase.kt b/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/TrackersStatisticsUseCase.kt index 1fddb74..5abe0b8 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/TrackersStatisticsUseCase.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/domain/usecases/TrackersStatisticsUseCase.kt @@ -19,6 +19,7 @@ package foundation.e.privacycentralapp.domain.usecases import android.content.res.Resources import foundation.e.privacycentralapp.R +import foundation.e.privacycentralapp.common.throttleFirst import foundation.e.privacycentralapp.data.repositories.AppListsRepository import foundation.e.privacycentralapp.domain.entities.AppWithCounts import foundation.e.privacycentralapp.domain.entities.TrackersPeriodicStatistics @@ -26,14 +27,18 @@ import foundation.e.privacymodules.permissions.data.ApplicationDescription import foundation.e.privacymodules.trackers.IBlockTrackersPrivacyModule import foundation.e.privacymodules.trackers.ITrackTrackersPrivacyModule import foundation.e.privacymodules.trackers.Tracker +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds class TrackersStatisticsUseCase( private val trackTrackersPrivacyModule: ITrackTrackersPrivacyModule, @@ -45,16 +50,21 @@ class TrackersStatisticsUseCase( appListsRepository.getVisibleApps() } - fun listenUpdates(): Flow<Unit> = callbackFlow { + private fun rawUpdates(): Flow<Unit> = callbackFlow { val listener = object : ITrackTrackersPrivacyModule.Listener { override fun onNewData() { - offer(Unit) + trySend(Unit) } } trackTrackersPrivacyModule.addListener(listener) awaitClose { trackTrackersPrivacyModule.removeListener(listener) } } + @OptIn(FlowPreview::class) + fun listenUpdates(debounce: Duration = 1.seconds) = rawUpdates() + .throttleFirst(windowDuration = debounce) + .onStart { emit(Unit) } + fun getDayStatistics(): Pair<TrackersPeriodicStatistics, Int> { return TrackersPeriodicStatistics( callsBlockedNLeaked = trackTrackersPrivacyModule.getPastDayTrackersCalls(), diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/dashboard/DashboardFeature.kt b/app/src/main/java/foundation/e/privacycentralapp/features/dashboard/DashboardFeature.kt deleted file mode 100644 index 95a8cfe..0000000 --- a/app/src/main/java/foundation/e/privacycentralapp/features/dashboard/DashboardFeature.kt +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Copyright (C) 2021 E FOUNDATION - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package foundation.e.privacycentralapp.features.dashboard - -import android.util.Log -import foundation.e.flowmvi.Actor -import foundation.e.flowmvi.Reducer -import foundation.e.flowmvi.SingleEventProducer -import foundation.e.flowmvi.feature.BaseFeature -import foundation.e.privacycentralapp.R -import foundation.e.privacycentralapp.domain.entities.LocationMode -import foundation.e.privacycentralapp.domain.entities.QuickPrivacyState -import foundation.e.privacycentralapp.domain.usecases.GetQuickPrivacyStateUseCase -import foundation.e.privacycentralapp.domain.usecases.TrackersStatisticsUseCase -import foundation.e.privacymodules.permissions.data.ApplicationDescription -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge - -// Define a state machine for Dashboard Feature -class DashboardFeature( - initialState: State, - coroutineScope: CoroutineScope, - reducer: Reducer<State, Effect>, - actor: Actor<State, Action, Effect>, - singleEventProducer: SingleEventProducer<State, Action, Effect, SingleEvent> -) : BaseFeature<DashboardFeature.State, - DashboardFeature.Action, - DashboardFeature.Effect, - DashboardFeature.SingleEvent>( - initialState, actor, reducer, coroutineScope, { message -> Log.d("DashboardFeature", message) }, - singleEventProducer -) { - data class State( - val quickPrivacyState: QuickPrivacyState = QuickPrivacyState.DISABLED, - val isTrackersDenied: Boolean = false, - val isLocationHidden: Boolean = false, - val isIpHidden: Boolean? = false, - val locationMode: LocationMode = LocationMode.REAL_LOCATION, - val leakedTrackersCount: Int? = null, - val trackersCount: Int? = null, - val allowedTrackersCount: Int? = null, - val dayStatistics: List<Pair<Int, Int>>? = null, - val dayLabels: List<String>? = null, - val showQuickPrivacyDisabledMessage: Boolean = false - ) - - sealed class SingleEvent { - object NavigateToTrackersSingleEvent : SingleEvent() - object NavigateToInternetActivityPrivacySingleEvent : SingleEvent() - object NavigateToLocationSingleEvent : SingleEvent() - object NavigateToPermissionsSingleEvent : SingleEvent() - data class NavigateToAppDetailsEvent(val appDesc: ApplicationDescription) : SingleEvent() - object NewStatisticsAvailableSingleEvent : SingleEvent() - data class ToastMessageSingleEvent(val message: Int) : SingleEvent() - } - - sealed class Action { - object InitAction : Action() - object TogglePrivacyAction : Action() - object ShowFakeMyLocationAction : Action() - object ShowInternetActivityPrivacyAction : Action() - object ShowAppsPermissions : Action() - object ShowTrackers : Action() - object FetchStatistics : Action() - object CloseQuickPrivacyDisabledMessage : Action() - object ShowMostLeakedApp : Action() - } - - sealed class Effect { - object NoEffect : Effect() - data class UpdateStateEffect(val state: QuickPrivacyState) : Effect() - data class IpScramblingModeUpdatedEffect(val isIpHidden: Boolean?) : Effect() - data class TrackersStatisticsUpdatedEffect( - val dayStatistics: List<Pair<Int, Int>>, - val dayLabels: List<String>, - val dayTrackersCount: Int, - val trackersCount: Int, - val allowedTrackersCount: Int - ) : Effect() - data class TrackersBlockedUpdatedEffect(val areAllTrackersBlocked: Boolean) : Effect() - data class UpdateLocationModeEffect(val mode: LocationMode) : Effect() - object OpenFakeMyLocationEffect : Effect() - object OpenInternetActivityPrivacyEffect : Effect() - object OpenAppsPermissionsEffect : Effect() - object OpenTrackersEffect : Effect() - object NewStatisticsAvailablesEffect : Effect() - object FirstIPTrackerActivationEffect : Effect() - data class LocationHiddenUpdatedEffect(val isLocationHidden: Boolean) : Effect() - data class ShowQuickPrivacyDisabledMessageEffect(val show: Boolean) : Effect() - data class OpenAppDetailsEffect(val appDesc: ApplicationDescription) : Effect() - } - - companion object { - fun create( - coroutineScope: CoroutineScope, - getPrivacyStateUseCase: GetQuickPrivacyStateUseCase, - trackersStatisticsUseCase: TrackersStatisticsUseCase, - ): DashboardFeature = - DashboardFeature( - initialState = State(), - coroutineScope, - reducer = { state, effect -> - when (effect) { - is Effect.UpdateStateEffect -> state.copy(quickPrivacyState = effect.state) - is Effect.IpScramblingModeUpdatedEffect -> state.copy(isIpHidden = effect.isIpHidden) - is Effect.TrackersStatisticsUpdatedEffect -> state.copy( - dayStatistics = effect.dayStatistics, - dayLabels = effect.dayLabels, - leakedTrackersCount = effect.dayTrackersCount, - trackersCount = effect.trackersCount, - allowedTrackersCount = effect.allowedTrackersCount - ) - - is Effect.TrackersBlockedUpdatedEffect -> state.copy( - isTrackersDenied = effect.areAllTrackersBlocked - ) - is Effect.LocationHiddenUpdatedEffect -> state.copy( - isLocationHidden = effect.isLocationHidden - ) - is Effect.UpdateLocationModeEffect -> state.copy(locationMode = effect.mode) - is Effect.ShowQuickPrivacyDisabledMessageEffect -> state.copy(showQuickPrivacyDisabledMessage = effect.show) - else -> state - } - }, - actor = { _: State, action: Action -> - when (action) { - Action.TogglePrivacyAction -> { - val isFirstActivation = getPrivacyStateUseCase.toggleReturnIsFirstActivation() - flow { - emit(Effect.NewStatisticsAvailablesEffect) - if (isFirstActivation) emit(Effect.FirstIPTrackerActivationEffect) - } - } - - Action.InitAction -> { - trackersStatisticsUseCase.initAppList() - merge( - getPrivacyStateUseCase.quickPrivacyState.map { - Effect.UpdateStateEffect(it) - }, - getPrivacyStateUseCase.isIpHidden.map { - Effect.IpScramblingModeUpdatedEffect(it) - }, - trackersStatisticsUseCase.listenUpdates().map { - Effect.NewStatisticsAvailablesEffect - }, - getPrivacyStateUseCase.isTrackersDenied.map { - Effect.TrackersBlockedUpdatedEffect(it) - }, - getPrivacyStateUseCase.isLocationHidden.map { - Effect.LocationHiddenUpdatedEffect(it) - }, - getPrivacyStateUseCase.locationMode.map { - Effect.UpdateLocationModeEffect(it) - }, - getPrivacyStateUseCase.showQuickPrivacyDisabledMessage.map { - Effect.ShowQuickPrivacyDisabledMessageEffect(it) - }, - ) - } - Action.ShowFakeMyLocationAction -> flowOf(Effect.OpenFakeMyLocationEffect) - Action.ShowAppsPermissions -> flowOf(Effect.OpenAppsPermissionsEffect) - Action.ShowInternetActivityPrivacyAction -> flowOf( - Effect.OpenInternetActivityPrivacyEffect - ) - Action.ShowTrackers -> flowOf(Effect.OpenTrackersEffect) - Action.FetchStatistics -> - trackersStatisticsUseCase.getNonBlockedTrackersCount() - .map { nonBlockedTrackersCount -> - trackersStatisticsUseCase.getDayStatistics() - .let { (dayStatistics, trackersCount) -> - Effect.TrackersStatisticsUpdatedEffect( - dayStatistics = dayStatistics.callsBlockedNLeaked, - dayLabels = dayStatistics.periods, - dayTrackersCount = dayStatistics.trackersCount, - trackersCount = trackersCount, - allowedTrackersCount = nonBlockedTrackersCount - ) - } - } - is Action.CloseQuickPrivacyDisabledMessage -> { - getPrivacyStateUseCase.resetQuickPrivacyDisabledMessage() - flowOf(Effect.NoEffect) - } - is Action.ShowMostLeakedApp -> { - Log.d("mostleak", "Action.ShowMostLeakedApp") - flowOf( - trackersStatisticsUseCase.getMostLeakedApp()?.let { Effect.OpenAppDetailsEffect(appDesc = it) } ?: Effect.OpenTrackersEffect - ) - } - } - }, - singleEventProducer = { _, _, effect -> - when (effect) { - is Effect.OpenFakeMyLocationEffect -> - SingleEvent.NavigateToLocationSingleEvent - is Effect.OpenInternetActivityPrivacyEffect -> - SingleEvent.NavigateToInternetActivityPrivacySingleEvent - is Effect.OpenAppsPermissionsEffect -> - SingleEvent.NavigateToPermissionsSingleEvent - is Effect.OpenTrackersEffect -> - SingleEvent.NavigateToTrackersSingleEvent - is Effect.NewStatisticsAvailablesEffect -> - SingleEvent.NewStatisticsAvailableSingleEvent - is Effect.FirstIPTrackerActivationEffect -> - SingleEvent.ToastMessageSingleEvent( - message = R.string.dashboard_first_ipscrambling_activation - ) - is Effect.OpenAppDetailsEffect -> SingleEvent.NavigateToAppDetailsEvent(effect.appDesc) - else -> null - } - } - ) - } -} diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/dashboard/DashboardFragment.kt b/app/src/main/java/foundation/e/privacycentralapp/features/dashboard/DashboardFragment.kt index 323f1bb..adb54bb 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/features/dashboard/DashboardFragment.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/features/dashboard/DashboardFragment.kt @@ -26,12 +26,13 @@ import android.widget.Toast import androidx.core.content.ContextCompat.getColor import androidx.core.os.bundleOf import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels import androidx.fragment.app.commit import androidx.fragment.app.replace +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.snackbar.Snackbar -import foundation.e.flowmvi.MVIView import foundation.e.privacycentralapp.DependencyContainer import foundation.e.privacycentralapp.PrivacyCentralApplication import foundation.e.privacycentralapp.R @@ -41,23 +42,15 @@ import foundation.e.privacycentralapp.common.initQuickPrivacySnackbar import foundation.e.privacycentralapp.databinding.FragmentDashboardBinding import foundation.e.privacycentralapp.domain.entities.LocationMode import foundation.e.privacycentralapp.domain.entities.QuickPrivacyState -import foundation.e.privacycentralapp.extensions.viewModelProviderFactoryOf -import foundation.e.privacycentralapp.features.dashboard.DashboardFeature.State +import foundation.e.privacycentralapp.features.dashboard.DashboardViewModel.Action +import foundation.e.privacycentralapp.features.dashboard.DashboardViewModel.SingleEvent import foundation.e.privacycentralapp.features.internetprivacy.InternetPrivacyFragment import foundation.e.privacycentralapp.features.location.FakeLocationFragment import foundation.e.privacycentralapp.features.trackers.TrackersFragment import foundation.e.privacycentralapp.features.trackers.apptrackers.AppTrackersFragment -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch -@FlowPreview -class DashboardFragment : - NavToolbarFragment(R.layout.fragment_dashboard), - MVIView<DashboardFeature.State, DashboardFeature.Action> { - +class DashboardFragment : NavToolbarFragment(R.layout.fragment_dashboard) { companion object { private const val PARAM_HIGHLIGHT_INDEX = "PARAM_HIGHLIGHT_INDEX" fun buildArgs(highlightIndex: Int): Bundle = bundleOf( @@ -69,8 +62,8 @@ class DashboardFragment : (this.requireActivity().application as PrivacyCentralApplication).dependencyContainer } - private val viewModel: DashboardViewModel by activityViewModels { - viewModelProviderFactoryOf { dependencyContainer.dashBoardViewModelFactory.create() } + private val viewModel: DashboardViewModel by viewModels { + dependencyContainer.viewModelsFactory } private var graphHolder: GraphHolder? = null @@ -82,64 +75,10 @@ class DashboardFragment : private var highlightIndexOnStart: Int? = null - private var updateUIJob: Job? = null - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) highlightIndexOnStart = arguments?.getInt(PARAM_HIGHLIGHT_INDEX, -1) - - updateUIJob = lifecycleScope.launchWhenStarted { - viewModel.dashboardFeature.takeView(this, this@DashboardFragment) - } - - lifecycleScope.launchWhenStarted { - viewModel.dashboardFeature.singleEvents.collect { event -> - when (event) { - is DashboardFeature.SingleEvent.NavigateToLocationSingleEvent -> { - requireActivity().supportFragmentManager.commit { - replace<FakeLocationFragment>(R.id.container) - setReorderingAllowed(true) - addToBackStack("dashboard") - } - } - is DashboardFeature.SingleEvent.NavigateToInternetActivityPrivacySingleEvent -> { - requireActivity().supportFragmentManager.commit { - replace<InternetPrivacyFragment>(R.id.container) - setReorderingAllowed(true) - addToBackStack("dashboard") - } - } - is DashboardFeature.SingleEvent.NavigateToPermissionsSingleEvent -> { - val intent = Intent("android.intent.action.MANAGE_PERMISSIONS") - requireActivity().startActivity(intent) - } - DashboardFeature.SingleEvent.NavigateToTrackersSingleEvent -> { - requireActivity().supportFragmentManager.commit { - replace<TrackersFragment>(R.id.container) - setReorderingAllowed(true) - addToBackStack("dashboard") - } - } - is DashboardFeature.SingleEvent.NavigateToAppDetailsEvent -> { - requireActivity().supportFragmentManager.commit { - replace<AppTrackersFragment>(R.id.container, args = AppTrackersFragment.buildArgs(event.appDesc.label.toString(), event.appDesc.packageName)) - setReorderingAllowed(true) - addToBackStack("dashboard") - } - } - DashboardFeature.SingleEvent.NewStatisticsAvailableSingleEvent -> { - viewModel.submitAction(DashboardFeature.Action.FetchStatistics) - } - is DashboardFeature.SingleEvent.ToastMessageSingleEvent -> - Toast.makeText(requireContext(), event.message, Toast.LENGTH_LONG) - .show() - } - } - } - lifecycleScope.launchWhenStarted { - viewModel.submitAction(DashboardFeature.Action.InitAction) - } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -149,54 +88,99 @@ class DashboardFragment : graphHolder = GraphHolder(binding.graph, requireContext()) binding.leakingAppButton.setOnClickListener { - viewModel.submitAction(DashboardFeature.Action.ShowMostLeakedApp) + viewModel.submitAction(Action.ShowMostLeakedApp) } binding.togglePrivacyCentral.setOnClickListener { - viewModel.submitAction(DashboardFeature.Action.TogglePrivacyAction) + viewModel.submitAction(Action.TogglePrivacyAction) } binding.myLocation.container.setOnClickListener { - viewModel.submitAction(DashboardFeature.Action.ShowFakeMyLocationAction) + viewModel.submitAction(Action.ShowFakeMyLocationAction) } binding.internetActivityPrivacy.container.setOnClickListener { - viewModel.submitAction(DashboardFeature.Action.ShowInternetActivityPrivacyAction) + viewModel.submitAction(Action.ShowInternetActivityPrivacyAction) } binding.appsPermissions.container.setOnClickListener { - viewModel.submitAction(DashboardFeature.Action.ShowAppsPermissions) + viewModel.submitAction(Action.ShowAppsPermissions) } binding.amITracked.container.setOnClickListener { - viewModel.submitAction(DashboardFeature.Action.ShowTrackers) + viewModel.submitAction(Action.ShowTrackers) } qpDisabledSnackbar = initQuickPrivacySnackbar(binding.root) { - viewModel.submitAction(DashboardFeature.Action.CloseQuickPrivacyDisabledMessage) + viewModel.submitAction(Action.CloseQuickPrivacyDisabledMessage) } - } - - override fun onResume() { - super.onResume() - if (updateUIJob == null || updateUIJob?.isActive == false) { - updateUIJob = lifecycleScope.launch { - viewModel.dashboardFeature.takeView(this, this@DashboardFragment) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + render(viewModel.state.value) + viewModel.state.collect(::render) } } - render(viewModel.dashboardFeature.state.value) - - viewModel.submitAction(DashboardFeature.Action.FetchStatistics) - } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect { event -> + when (event) { + is SingleEvent.NavigateToLocationSingleEvent -> { + requireActivity().supportFragmentManager.commit { + replace<FakeLocationFragment>(R.id.container) + setReorderingAllowed(true) + addToBackStack("dashboard") + } + } + is SingleEvent.NavigateToInternetActivityPrivacySingleEvent -> { + requireActivity().supportFragmentManager.commit { + replace<InternetPrivacyFragment>(R.id.container) + setReorderingAllowed(true) + addToBackStack("dashboard") + } + } + is SingleEvent.NavigateToPermissionsSingleEvent -> { + val intent = Intent("android.intent.action.MANAGE_PERMISSIONS") + requireActivity().startActivity(intent) + } + SingleEvent.NavigateToTrackersSingleEvent -> { + requireActivity().supportFragmentManager.commit { + replace<TrackersFragment>(R.id.container) + setReorderingAllowed(true) + addToBackStack("dashboard") + } + } + is SingleEvent.NavigateToAppDetailsEvent -> { + requireActivity().supportFragmentManager.commit { + replace<AppTrackersFragment>( + R.id.container, + args = AppTrackersFragment.buildArgs( + event.appDesc.label.toString(), + event.appDesc.packageName, + event.appDesc.uid + ) + ) + setReorderingAllowed(true) + addToBackStack("dashboard") + } + } + is SingleEvent.ToastMessageSingleEvent -> + Toast.makeText(requireContext(), event.message, Toast.LENGTH_LONG) + .show() + } + } + } + } - override fun onPause() { - super.onPause() - updateUIJob?.cancel() + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } + } } override fun getTitle(): String { return getString(R.string.dashboard_title) } - override fun render(state: State) { + private fun render(state: DashboardState) { if (state.showQuickPrivacyDisabledMessage) qpDisabledSnackbar?.show() else qpDisabledSnackbar?.dismiss() @@ -308,8 +292,6 @@ class DashboardFragment : binding.executePendingBindings() } - override fun actions(): Flow<DashboardFeature.Action> = viewModel.actions - override fun onDestroyView() { super.onDestroyView() qpDisabledSnackbar = null diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/dashboard/DashboardState.kt b/app/src/main/java/foundation/e/privacycentralapp/features/dashboard/DashboardState.kt new file mode 100644 index 0000000..65aa444 --- /dev/null +++ b/app/src/main/java/foundation/e/privacycentralapp/features/dashboard/DashboardState.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.privacycentralapp.features.dashboard + +import foundation.e.privacycentralapp.domain.entities.LocationMode +import foundation.e.privacycentralapp.domain.entities.QuickPrivacyState + +data class DashboardState( + val quickPrivacyState: QuickPrivacyState = QuickPrivacyState.DISABLED, + val isTrackersDenied: Boolean = false, + val isLocationHidden: Boolean = false, + val isIpHidden: Boolean? = false, + val locationMode: LocationMode = LocationMode.REAL_LOCATION, + val leakedTrackersCount: Int? = null, + val trackersCount: Int? = null, + val allowedTrackersCount: Int? = null, + val dayStatistics: List<Pair<Int, Int>>? = null, + val dayLabels: List<String>? = null, + val showQuickPrivacyDisabledMessage: Boolean = false +)
\ No newline at end of file diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/dashboard/DashboardViewModel.kt b/app/src/main/java/foundation/e/privacycentralapp/features/dashboard/DashboardViewModel.kt index ffd7951..e3a9722 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/features/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/features/dashboard/DashboardViewModel.kt @@ -19,41 +19,131 @@ package foundation.e.privacycentralapp.features.dashboard import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import foundation.e.privacycentralapp.common.Factory +import foundation.e.privacycentralapp.R import foundation.e.privacycentralapp.domain.usecases.GetQuickPrivacyStateUseCase import foundation.e.privacycentralapp.domain.usecases.TrackersStatisticsUseCase +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class DashboardViewModel( private val getPrivacyStateUseCase: GetQuickPrivacyStateUseCase, private val trackersStatisticsUseCase: TrackersStatisticsUseCase, ) : ViewModel() { - private val _actions = MutableSharedFlow<DashboardFeature.Action>() - val actions = _actions.asSharedFlow() + private val _state = MutableStateFlow(DashboardState()) + val state = _state.asStateFlow() - val dashboardFeature: DashboardFeature by lazy { - DashboardFeature.create( - coroutineScope = viewModelScope, - getPrivacyStateUseCase = getPrivacyStateUseCase, - trackersStatisticsUseCase = trackersStatisticsUseCase, - ) + private val _singleEvents = MutableSharedFlow<SingleEvent>() + val singleEvents = _singleEvents.asSharedFlow() + + init { + viewModelScope.launch(Dispatchers.IO) { trackersStatisticsUseCase.initAppList() } } - fun submitAction(action: DashboardFeature.Action) { - viewModelScope.launch { - _actions.emit(action) + suspend fun doOnStartedState() = withContext(Dispatchers.IO) { + merge( + getPrivacyStateUseCase.quickPrivacyState.map { + _state.update { s -> s.copy(quickPrivacyState = it) } + }, + getPrivacyStateUseCase.isIpHidden.map { + _state.update { s -> s.copy(isIpHidden = it) } + }, + trackersStatisticsUseCase.listenUpdates().flatMapLatest { + fetchStatistics() + }, + getPrivacyStateUseCase.isTrackersDenied.map { + _state.update { s -> s.copy(isTrackersDenied = it) } + }, + getPrivacyStateUseCase.isLocationHidden.map { + _state.update { s -> s.copy(isLocationHidden = it) } + }, + getPrivacyStateUseCase.locationMode.map { + _state.update { s -> s.copy(locationMode = it) } + }, + getPrivacyStateUseCase.showQuickPrivacyDisabledMessage.map { + _state.update { s -> s.copy(showQuickPrivacyDisabledMessage = it) } + } + ).collect {} + } + + fun submitAction(action: Action) = viewModelScope.launch { + when (action) { + is Action.TogglePrivacyAction -> actionTogglePrivacy() + is Action.ShowFakeMyLocationAction -> + _singleEvents.emit(SingleEvent.NavigateToLocationSingleEvent) + is Action.ShowAppsPermissions -> + _singleEvents.emit(SingleEvent.NavigateToPermissionsSingleEvent) + is Action.ShowInternetActivityPrivacyAction -> + _singleEvents.emit(SingleEvent.NavigateToInternetActivityPrivacySingleEvent) + is Action.ShowTrackers -> + _singleEvents.emit(SingleEvent.NavigateToTrackersSingleEvent) + is Action.CloseQuickPrivacyDisabledMessage -> + getPrivacyStateUseCase.resetQuickPrivacyDisabledMessage() + is Action.ShowMostLeakedApp -> actionShowMostLeakedApp() } } -} -class DashBoardViewModelFactory( - private val getPrivacyStateUseCase: GetQuickPrivacyStateUseCase, - private val trackersStatisticsUseCase: TrackersStatisticsUseCase, -) : Factory<DashboardViewModel> { - override fun create(): DashboardViewModel { - return DashboardViewModel(getPrivacyStateUseCase, trackersStatisticsUseCase) + private suspend fun fetchStatistics(): Flow<Unit> = withContext(Dispatchers.IO) { + trackersStatisticsUseCase.getNonBlockedTrackersCount().map { nonBlockedTrackersCount -> + trackersStatisticsUseCase.getDayStatistics().let { (dayStatistics, trackersCount) -> + _state.update { s -> + s.copy( + dayStatistics = dayStatistics.callsBlockedNLeaked, + dayLabels = dayStatistics.periods, + leakedTrackersCount = dayStatistics.trackersCount, + trackersCount = trackersCount, + allowedTrackersCount = nonBlockedTrackersCount + ) + } + } + } + } + + private suspend fun actionTogglePrivacy() = withContext(Dispatchers.IO) { + val isFirstActivation = getPrivacyStateUseCase.toggleReturnIsFirstActivation() + fetchStatistics().first() + + if (isFirstActivation) _singleEvents.emit(SingleEvent.ToastMessageSingleEvent( + message = R.string.dashboard_first_ipscrambling_activation + )) + } + + private suspend fun actionShowMostLeakedApp() = withContext(Dispatchers.IO) { + _singleEvents.emit( + trackersStatisticsUseCase.getMostLeakedApp()?.let { + SingleEvent.NavigateToAppDetailsEvent(appDesc = it) + } ?: SingleEvent.NavigateToTrackersSingleEvent + ) + } + + sealed class SingleEvent { + object NavigateToTrackersSingleEvent : SingleEvent() + object NavigateToInternetActivityPrivacySingleEvent : SingleEvent() + object NavigateToLocationSingleEvent : SingleEvent() + object NavigateToPermissionsSingleEvent : SingleEvent() + data class NavigateToAppDetailsEvent(val appDesc: ApplicationDescription) : SingleEvent() + data class ToastMessageSingleEvent(val message: Int) : SingleEvent() + } + + sealed class Action { + object TogglePrivacyAction : Action() + object ShowFakeMyLocationAction : Action() + object ShowInternetActivityPrivacyAction : Action() + object ShowAppsPermissions : Action() + object ShowTrackers : Action() + object CloseQuickPrivacyDisabledMessage : Action() + object ShowMostLeakedApp : Action() } } diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/internetprivacy/InternetPrivacyFeature.kt b/app/src/main/java/foundation/e/privacycentralapp/features/internetprivacy/InternetPrivacyFeature.kt deleted file mode 100644 index 8e4318d..0000000 --- a/app/src/main/java/foundation/e/privacycentralapp/features/internetprivacy/InternetPrivacyFeature.kt +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Copyright (C) 2021 E FOUNDATION - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package foundation.e.privacycentralapp.features.internetprivacy - -import android.app.Activity -import android.content.Intent -import android.util.Log -import foundation.e.flowmvi.Actor -import foundation.e.flowmvi.Reducer -import foundation.e.flowmvi.SingleEventProducer -import foundation.e.flowmvi.feature.BaseFeature -import foundation.e.privacycentralapp.R -import foundation.e.privacycentralapp.domain.entities.InternetPrivacyMode -import foundation.e.privacycentralapp.domain.usecases.AppListUseCase -import foundation.e.privacycentralapp.domain.usecases.GetQuickPrivacyStateUseCase -import foundation.e.privacycentralapp.domain.usecases.IpScramblingStateUseCase -import foundation.e.privacymodules.ipscramblermodule.IIpScramblerModule -import foundation.e.privacymodules.permissions.data.ApplicationDescription -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.shareIn - -// Define a state machine for Internet privacy feature -class InternetPrivacyFeature( - initialState: State, - coroutineScope: CoroutineScope, - reducer: Reducer<State, Effect>, - actor: Actor<State, Action, Effect>, - singleEventProducer: SingleEventProducer<State, Action, Effect, SingleEvent> -) : BaseFeature<InternetPrivacyFeature.State, InternetPrivacyFeature.Action, InternetPrivacyFeature.Effect, InternetPrivacyFeature.SingleEvent>( - initialState, - actor, - reducer, - coroutineScope, - { message -> Log.d("InternetPrivacyFeature", message) }, - singleEventProducer -) { - data class State( - val mode: InternetPrivacyMode, - val availableApps: List<ApplicationDescription>, - val bypassTorApps: Collection<String>, - val selectedLocation: String, - val availableLocationIds: List<String>, - val forceRedraw: Boolean = false, - val showQuickPrivacyDisabledMessage: Boolean = false - ) { - fun getApps(): List<Pair<ApplicationDescription, Boolean>> { - return availableApps.map { it to (it.packageName !in bypassTorApps) } - } - - val selectedLocationPosition get() = availableLocationIds.indexOf(selectedLocation) - } - - sealed class SingleEvent { - data class StartAndroidVpnActivityEvent(val intent: Intent) : SingleEvent() - data class ErrorEvent(val error: Any) : SingleEvent() - } - - sealed class Action { - object LoadInternetModeAction : Action() - object UseRealIPAction : Action() - object UseHiddenIPAction : Action() - data class AndroidVpnActivityResultAction(val resultCode: Int) : Action() - data class ToggleAppIpScrambled(val packageName: String) : Action() - data class SelectLocationAction(val position: Int) : Action() - object CloseQuickPrivacyDisabledMessage : Action() - } - - sealed class Effect { - object NoEffect : Effect() - data class ModeUpdatedEffect(val mode: InternetPrivacyMode) : Effect() - data class QuickPrivacyUpdatedEffect(val enabled: Boolean) : Effect() - object QuickPrivacyDisabledWarningEffect : Effect() - data class ShowAndroidVpnDisclaimerEffect(val intent: Intent) : Effect() - data class IpScrambledAppsUpdatedEffect(val bypassTorApps: Collection<String>) : Effect() - data class AvailableAppsListEffect( - val apps: List<ApplicationDescription>, - val bypassTorApps: Collection<String> - ) : Effect() - data class LocationSelectedEffect(val locationId: String) : Effect() - object WarningStartingLongEffect : Effect() - data class ErrorEffect(val message: String) : Effect() - data class ShowQuickPrivacyDisabledMessageEffect(val show: Boolean) : Effect() - } - - companion object { - private const val WARNING_LOADING_LONG_DELAY = 5 * 1000L - @FlowPreview - fun create( - coroutineScope: CoroutineScope, - ipScramblerModule: IIpScramblerModule, - getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase, - ipScramblingStateUseCase: IpScramblingStateUseCase, - appListUseCase: AppListUseCase, - availablesLocationsIds: List<String>, - initialState: State = State( - mode = ipScramblingStateUseCase.internetPrivacyMode.value, - availableApps = emptyList(), - bypassTorApps = emptyList(), - availableLocationIds = availablesLocationsIds, - selectedLocation = "" - ) - ) = InternetPrivacyFeature( - initialState, coroutineScope, - reducer = { state, effect -> - when (effect) { - is Effect.ModeUpdatedEffect -> state.copy(mode = effect.mode) - is Effect.IpScrambledAppsUpdatedEffect -> state.copy(bypassTorApps = effect.bypassTorApps) - is Effect.AvailableAppsListEffect -> state.copy( - availableApps = effect.apps, - bypassTorApps = effect.bypassTorApps - ) - is Effect.LocationSelectedEffect -> state.copy(selectedLocation = effect.locationId) - Effect.QuickPrivacyDisabledWarningEffect -> state.copy(forceRedraw = !state.forceRedraw) - is Effect.ShowQuickPrivacyDisabledMessageEffect -> state.copy(showQuickPrivacyDisabledMessage = effect.show) - else -> state - } - }, - actor = { state, action -> - when { - action is Action.LoadInternetModeAction -> merge( - getQuickPrivacyStateUseCase.quickPrivacyEnabledFlow - .map { Effect.QuickPrivacyUpdatedEffect(it) }, - getQuickPrivacyStateUseCase.showQuickPrivacyDisabledMessage.map { - Effect.ShowQuickPrivacyDisabledMessageEffect(it) - }, - getQuickPrivacyStateUseCase.quickPrivacyEnabledFlow.flatMapLatest { enabled -> - if (enabled) ipScramblingStateUseCase.internetPrivacyMode - .map { Effect.ModeUpdatedEffect(it) } - .shareIn( - scope = coroutineScope, - started = SharingStarted.Lazily, - replay = 0 - ) - else ipScramblingStateUseCase.configuredMode.map { - Effect.ModeUpdatedEffect( - if (it) InternetPrivacyMode.HIDE_IP - else InternetPrivacyMode.REAL_IP - ) - } - }, - appListUseCase.getAppsUsingInternet().map { apps -> - Effect.AvailableAppsListEffect( - apps, - ipScramblingStateUseCase.bypassTorApps - ) - }, - flowOf(Effect.LocationSelectedEffect(ipScramblerModule.exitCountry)), - ipScramblingStateUseCase.internetPrivacyMode - .map { it == InternetPrivacyMode.HIDE_IP_LOADING } - .debounce(WARNING_LOADING_LONG_DELAY) - .map { if (it) Effect.WarningStartingLongEffect else Effect.NoEffect } - ).flowOn(Dispatchers.Default) - action is Action.AndroidVpnActivityResultAction -> - if (action.resultCode == Activity.RESULT_OK) { - if (state.mode in listOf( - InternetPrivacyMode.REAL_IP, - InternetPrivacyMode.REAL_IP_LOADING - ) - ) { - ipScramblingStateUseCase.toggle(hideIp = true) - flowOf(Effect.ModeUpdatedEffect(InternetPrivacyMode.HIDE_IP_LOADING)) - } else { - flowOf(Effect.ErrorEffect("Vpn already started")) - } - } else { - flowOf(Effect.ErrorEffect("Vpn wasn't allowed to start")) - } - - action is Action.UseRealIPAction && state.mode in listOf( - InternetPrivacyMode.HIDE_IP, - InternetPrivacyMode.HIDE_IP_LOADING, - InternetPrivacyMode.REAL_IP_LOADING - ) -> { - ipScramblingStateUseCase.toggle(hideIp = false) - flowOf(Effect.ModeUpdatedEffect(InternetPrivacyMode.REAL_IP_LOADING)) - } - action is Action.UseHiddenIPAction - && state.mode in listOf( - InternetPrivacyMode.REAL_IP, - InternetPrivacyMode.REAL_IP_LOADING - ) -> { - ipScramblingStateUseCase.toggle(hideIp = true) - flowOf(Effect.ModeUpdatedEffect(InternetPrivacyMode.HIDE_IP_LOADING)) - } - - action is Action.ToggleAppIpScrambled -> { - ipScramblingStateUseCase.toggleBypassTor(action.packageName) - flowOf(Effect.IpScrambledAppsUpdatedEffect(bypassTorApps = ipScramblingStateUseCase.bypassTorApps)) - } - action is Action.SelectLocationAction -> { - val locationId = state.availableLocationIds[action.position] - if (locationId != ipScramblerModule.exitCountry) { - ipScramblerModule.exitCountry = locationId - flowOf(Effect.LocationSelectedEffect(locationId)) - } else { - flowOf(Effect.NoEffect) - } - } - action is Action.CloseQuickPrivacyDisabledMessage -> { - getQuickPrivacyStateUseCase.resetQuickPrivacyDisabledMessage() - flowOf(Effect.NoEffect) - } - else -> flowOf(Effect.NoEffect) - } - }, - singleEventProducer = { _, action, effect -> - when { - effect is Effect.ErrorEffect -> SingleEvent.ErrorEvent(effect.message) - effect is Effect.WarningStartingLongEffect -> - SingleEvent.ErrorEvent(R.string.ipscrambling_warning_starting_long) - action is Action.UseHiddenIPAction - && effect is Effect.ShowAndroidVpnDisclaimerEffect -> - SingleEvent.StartAndroidVpnActivityEvent(effect.intent) - else -> null - } - } - ) - } -} diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/internetprivacy/InternetPrivacyFragment.kt b/app/src/main/java/foundation/e/privacycentralapp/features/internetprivacy/InternetPrivacyFragment.kt index 59d30c8..ff8e78f 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/features/internetprivacy/InternetPrivacyFragment.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/features/internetprivacy/InternetPrivacyFragment.kt @@ -22,12 +22,12 @@ import android.view.View import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.snackbar.Snackbar -import foundation.e.flowmvi.MVIView import foundation.e.privacycentralapp.DependencyContainer import foundation.e.privacycentralapp.PrivacyCentralApplication import foundation.e.privacycentralapp.R @@ -36,24 +36,18 @@ import foundation.e.privacycentralapp.common.ToggleAppsAdapter import foundation.e.privacycentralapp.common.initQuickPrivacySnackbar import foundation.e.privacycentralapp.databinding.FragmentInternetActivityPolicyBinding import foundation.e.privacycentralapp.domain.entities.InternetPrivacyMode -import foundation.e.privacycentralapp.extensions.toText -import foundation.e.privacycentralapp.extensions.viewModelProviderFactoryOf -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect +import foundation.e.privacycentralapp.common.extensions.toText +import kotlinx.coroutines.launch import java.util.Locale -@FlowPreview -class InternetPrivacyFragment : - NavToolbarFragment(R.layout.fragment_internet_activity_policy), - MVIView<InternetPrivacyFeature.State, InternetPrivacyFeature.Action> { +class InternetPrivacyFragment : NavToolbarFragment(R.layout.fragment_internet_activity_policy) { private val dependencyContainer: DependencyContainer by lazy { (this.requireActivity().application as PrivacyCentralApplication).dependencyContainer } private val viewModel: InternetPrivacyViewModel by viewModels { - viewModelProviderFactoryOf { dependencyContainer.internetPrivacyViewModelFactory.create() } + dependencyContainer.viewModelsFactory } private var _binding: FragmentInternetActivityPolicyBinding? = null @@ -61,37 +55,11 @@ class InternetPrivacyFragment : private var qpDisabledSnackbar: Snackbar? = null - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycleScope.launchWhenStarted { - viewModel.internetPrivacyFeature.takeView(this, this@InternetPrivacyFragment) - } - lifecycleScope.launchWhenStarted { - viewModel.internetPrivacyFeature.singleEvents.collect { event -> - when (event) { - is InternetPrivacyFeature.SingleEvent.ErrorEvent -> { - displayToast(event.error.toText(requireContext())) - } - is InternetPrivacyFeature.SingleEvent.StartAndroidVpnActivityEvent -> { - launchAndroidVpnDisclaimer.launch(event.intent) - } - } - } - } - lifecycleScope.launchWhenStarted { - viewModel.submitAction(InternetPrivacyFeature.Action.LoadInternetModeAction) - } - } - private fun displayToast(message: String) { Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT) .show() } - private val launchAndroidVpnDisclaimer = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - viewModel.submitAction(InternetPrivacyFeature.Action.AndroidVpnActivityResultAction(it.resultCode)) - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) _binding = FragmentInternetActivityPolicyBinding.bind(view) @@ -101,17 +69,17 @@ class InternetPrivacyFragment : setHasFixedSize(true) adapter = ToggleAppsAdapter(R.layout.ipscrambling_item_app_toggle) { packageName -> viewModel.submitAction( - InternetPrivacyFeature.Action.ToggleAppIpScrambled(packageName) + InternetPrivacyViewModel.Action.ToggleAppIpScrambled(packageName) ) } } binding.radioUseRealIp.radiobutton.setOnClickListener { - viewModel.submitAction(InternetPrivacyFeature.Action.UseRealIPAction) + viewModel.submitAction(InternetPrivacyViewModel.Action.UseRealIPAction) } binding.radioUseHiddenIp.radiobutton.setOnClickListener { - viewModel.submitAction(InternetPrivacyFeature.Action.UseHiddenIPAction) + viewModel.submitAction(InternetPrivacyViewModel.Action.UseHiddenIPAction) } binding.ipscramblingSelectLocation.apply { @@ -129,8 +97,17 @@ class InternetPrivacyFragment : } onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parentView: AdapterView<*>, selectedItemView: View?, position: Int, id: Long) { - viewModel.submitAction(InternetPrivacyFeature.Action.SelectLocationAction(position)) + override fun onItemSelected( + parentView: AdapterView<*>, + selectedItemView: View?, + position: Int, + id: Long + ) { + viewModel.submitAction( + InternetPrivacyViewModel.Action.SelectLocationAction( + position + ) + ) } override fun onNothingSelected(parentView: AdapterView<*>?) {} @@ -138,15 +115,37 @@ class InternetPrivacyFragment : } qpDisabledSnackbar = initQuickPrivacySnackbar(binding.root) { - viewModel.submitAction(InternetPrivacyFeature.Action.CloseQuickPrivacyDisabledMessage) + viewModel.submitAction(InternetPrivacyViewModel.Action.CloseQuickPrivacyDisabledMessage) } - binding.executePendingBindings() + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + render(viewModel.state.value) + viewModel.state.collect(::render) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect { event -> + when (event) { + is InternetPrivacyViewModel.SingleEvent.ErrorEvent -> { + displayToast(event.error.toText(requireContext())) + } + } + } + } + } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } + } } override fun getTitle(): String = getString(R.string.ipscrambling_title) - override fun render(state: InternetPrivacyFeature.State) { + private fun render(state: InternetPrivacyState) { if (state.showQuickPrivacyDisabledMessage) qpDisabledSnackbar?.show() else qpDisabledSnackbar?.dismiss() @@ -200,8 +199,6 @@ class InternetPrivacyFragment : } } - override fun actions(): Flow<InternetPrivacyFeature.Action> = viewModel.actions - override fun onDestroyView() { super.onDestroyView() qpDisabledSnackbar = null diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/internetprivacy/InternetPrivacyState.kt b/app/src/main/java/foundation/e/privacycentralapp/features/internetprivacy/InternetPrivacyState.kt new file mode 100644 index 0000000..25e911f --- /dev/null +++ b/app/src/main/java/foundation/e/privacycentralapp/features/internetprivacy/InternetPrivacyState.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.privacycentralapp.features.internetprivacy + +import foundation.e.privacycentralapp.domain.entities.InternetPrivacyMode +import foundation.e.privacymodules.permissions.data.ApplicationDescription + +data class InternetPrivacyState( + val mode: InternetPrivacyMode = InternetPrivacyMode.REAL_IP, + val availableApps: List<ApplicationDescription> = emptyList(), + val bypassTorApps: Collection<String> = emptyList(), + val selectedLocation: String = "", + val availableLocationIds: List<String> = emptyList(), + val forceRedraw: Boolean = false, + val showQuickPrivacyDisabledMessage: Boolean = false +) { + fun getApps(): List<Pair<ApplicationDescription, Boolean>> { + return availableApps.map { it to (it.packageName !in bypassTorApps) } + } + + val selectedLocationPosition get() = availableLocationIds.indexOf(selectedLocation) +}
\ No newline at end of file diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/internetprivacy/InternetPrivacyViewModel.kt b/app/src/main/java/foundation/e/privacycentralapp/features/internetprivacy/InternetPrivacyViewModel.kt index 8bb7d9f..6d083bd 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/features/internetprivacy/InternetPrivacyViewModel.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/features/internetprivacy/InternetPrivacyViewModel.kt @@ -19,15 +19,24 @@ package foundation.e.privacycentralapp.features.internetprivacy import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import foundation.e.privacycentralapp.common.Factory +import foundation.e.privacycentralapp.R +import foundation.e.privacycentralapp.domain.entities.InternetPrivacyMode import foundation.e.privacycentralapp.domain.usecases.AppListUseCase import foundation.e.privacycentralapp.domain.usecases.GetQuickPrivacyStateUseCase import foundation.e.privacycentralapp.domain.usecases.IpScramblingStateUseCase import foundation.e.privacymodules.ipscramblermodule.IIpScramblerModule +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class InternetPrivacyViewModel( private val ipScramblerModule: IIpScramblerModule, @@ -35,38 +44,110 @@ class InternetPrivacyViewModel( private val ipScramblingStateUseCase: IpScramblingStateUseCase, private val appListUseCase: AppListUseCase ) : ViewModel() { + companion object { + private const val WARNING_LOADING_LONG_DELAY = 5 * 1000L + } + + private val _state = MutableStateFlow(InternetPrivacyState()) + val state = _state.asStateFlow() + + private val _singleEvents = MutableSharedFlow<SingleEvent>() + val singleEvents = _singleEvents.asSharedFlow() + - private val _actions = MutableSharedFlow<InternetPrivacyFeature.Action>() - val actions = _actions.asSharedFlow() val availablesLocationsIds = listOf("", *ipScramblerModule.getAvailablesLocations().sorted().toTypedArray()) - @FlowPreview val internetPrivacyFeature: InternetPrivacyFeature by lazy { - InternetPrivacyFeature.create( - coroutineScope = viewModelScope, - ipScramblerModule = ipScramblerModule, - getQuickPrivacyStateUseCase = getQuickPrivacyStateUseCase, - ipScramblingStateUseCase = ipScramblingStateUseCase, - appListUseCase = appListUseCase, - availablesLocationsIds = availablesLocationsIds - ) + init { + viewModelScope.launch(Dispatchers.IO) { + _state.update { it.copy( + mode = ipScramblingStateUseCase.internetPrivacyMode.value, + availableLocationIds = availablesLocationsIds, + selectedLocation = ipScramblerModule.exitCountry) } + } } - fun submitAction(action: InternetPrivacyFeature.Action) { - viewModelScope.launch { - _actions.emit(action) + + @OptIn(FlowPreview::class) + suspend fun doOnStartedState() = withContext(Dispatchers.IO) { + launch { + merge( + getQuickPrivacyStateUseCase.showQuickPrivacyDisabledMessage.map { + _state.update { s -> s.copy(showQuickPrivacyDisabledMessage = it) } + }, + appListUseCase.getAppsUsingInternet().map { apps -> + _state.update { s -> s.copy( + availableApps = apps, + bypassTorApps = ipScramblingStateUseCase.bypassTorApps + ) } + }, + if (getQuickPrivacyStateUseCase.isQuickPrivacyEnabled) + ipScramblingStateUseCase.internetPrivacyMode.map { + _state.update { s -> s.copy(mode = it) } + } + else ipScramblingStateUseCase.configuredMode.map { + _state.update { s -> s.copy( + mode = if (it) InternetPrivacyMode.HIDE_IP + else InternetPrivacyMode.REAL_IP + ) } + } + ).collect {} + + } + + launch { + ipScramblingStateUseCase.internetPrivacyMode + .map { it == InternetPrivacyMode.HIDE_IP_LOADING } + .debounce(WARNING_LOADING_LONG_DELAY) + .collect { + if (it) _singleEvents.emit( + SingleEvent.ErrorEvent(R.string.ipscrambling_warning_starting_long) + ) + } } } -} -class InternetPrivacyViewModelFactory( - private val ipScramblerModule: IIpScramblerModule, - private val getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase, - private val ipScramblingStateUseCase: IpScramblingStateUseCase, - private val appListUseCase: AppListUseCase -) : - Factory<InternetPrivacyViewModel> { - override fun create(): InternetPrivacyViewModel { - return InternetPrivacyViewModel(ipScramblerModule, getQuickPrivacyStateUseCase, ipScramblingStateUseCase, appListUseCase) + fun submitAction(action: Action) = viewModelScope.launch { + when (action) { + is Action.UseRealIPAction -> actionUseRealIP() + is Action.UseHiddenIPAction -> actionUseHiddenIP() + is Action.ToggleAppIpScrambled -> actionToggleAppIpScrambled(action) + is Action.SelectLocationAction -> actionSelectLocation(action) + is Action.CloseQuickPrivacyDisabledMessage -> + getQuickPrivacyStateUseCase.resetQuickPrivacyDisabledMessage() + } + } + + private fun actionUseRealIP() { + ipScramblingStateUseCase.toggle(hideIp = false) + } + + private fun actionUseHiddenIP() { + ipScramblingStateUseCase.toggle(hideIp = true) + } + + suspend private fun actionToggleAppIpScrambled(action: Action.ToggleAppIpScrambled) = withContext(Dispatchers.IO) { + ipScramblingStateUseCase.toggleBypassTor(action.packageName) + _state.update { it.copy(bypassTorApps = ipScramblingStateUseCase.bypassTorApps) } + } + + suspend private fun actionSelectLocation(action: Action.SelectLocationAction) = withContext(Dispatchers.IO) { + val locationId = _state.value.availableLocationIds[action.position] + if (locationId != ipScramblerModule.exitCountry) { + ipScramblerModule.exitCountry = locationId + _state.update { it.copy(selectedLocation = locationId) } + } + } + + sealed class SingleEvent { + data class ErrorEvent(val error: Any) : SingleEvent() + } + + sealed class Action { + object UseRealIPAction : Action() + object UseHiddenIPAction : Action() + data class ToggleAppIpScrambled(val packageName: String) : Action() + data class SelectLocationAction(val position: Int) : Action() + object CloseQuickPrivacyDisabledMessage : Action() } } diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationFeature.kt b/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationFeature.kt deleted file mode 100644 index 85a507d..0000000 --- a/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationFeature.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright (C) 2021 E FOUNDATION - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package foundation.e.privacycentralapp.features.location - -import android.location.Location -import android.util.Log -import foundation.e.flowmvi.Actor -import foundation.e.flowmvi.Reducer -import foundation.e.flowmvi.SingleEventProducer -import foundation.e.flowmvi.feature.BaseFeature -import foundation.e.privacycentralapp.domain.entities.LocationMode -import foundation.e.privacycentralapp.domain.usecases.FakeLocationStateUseCase -import foundation.e.privacycentralapp.domain.usecases.GetQuickPrivacyStateUseCase -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge - -// Define a state machine for Fake location feature -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 -) { - data class State( - val mode: LocationMode = LocationMode.REAL_LOCATION, - val currentLocation: Location? = null, - val specificLatitude: Float? = null, - val specificLongitude: Float? = null, - val forceRefresh: Boolean = false, - val showQuickPrivacyDisabledMessage: Boolean = false - ) - - sealed class SingleEvent { - data class LocationUpdatedEvent(val mode: LocationMode, val location: Location?) : SingleEvent() - data class ErrorEvent(val error: String) : SingleEvent() - } - - sealed class Action { - object Init : Action() - object LeaveScreen : Action() - object UseRealLocationAction : Action() - object UseRandomLocationAction : Action() - data class SetSpecificLocationAction( - val latitude: Float, - val longitude: Float - ) : Action() - object CloseQuickPrivacyDisabledMessage : Action() - } - - sealed class Effect { - data class LocationModeUpdatedEffect( - val mode: LocationMode, - val latitude: Float? = null, - val longitude: Float? = null - ) : Effect() - data class LocationUpdatedEffect(val location: Location?) : Effect() - data class ErrorEffect(val message: String) : Effect() - object NoEffect : Effect() - data class ShowQuickPrivacyDisabledMessageEffect(val show: Boolean) : Effect() - } - - companion object { - fun create( - initialState: State = State(), - getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase, - fakeLocationStateUseCase: FakeLocationStateUseCase, - coroutineScope: CoroutineScope - ) = FakeLocationFeature( - initialState, coroutineScope, - reducer = { state, effect -> - when (effect) { - is Effect.LocationModeUpdatedEffect -> state.copy( - mode = effect.mode, - specificLatitude = effect.latitude, - specificLongitude = effect.longitude - ) - is Effect.ShowQuickPrivacyDisabledMessageEffect -> state.copy(showQuickPrivacyDisabledMessage = effect.show) - else -> state - } - }, - actor = { _, action -> - when (action) { - is Action.Init -> { - fakeLocationStateUseCase.startListeningLocation() - merge( - fakeLocationStateUseCase.configuredLocationMode.map { (mode, lat, lon) -> - Effect.LocationModeUpdatedEffect(mode = mode, latitude = lat, longitude = lon) - }, - fakeLocationStateUseCase.currentLocation.map { Effect.LocationUpdatedEffect(it) }, - getQuickPrivacyStateUseCase.showQuickPrivacyDisabledMessage.map { Effect.ShowQuickPrivacyDisabledMessageEffect(it) }, - ) - } - is Action.LeaveScreen -> { - fakeLocationStateUseCase.stopListeningLocation() - flowOf(Effect.NoEffect) - } - is Action.SetSpecificLocationAction -> { - fakeLocationStateUseCase.setSpecificLocation( - action.latitude, - action.longitude - ) - flowOf(Effect.NoEffect) - } - is Action.UseRandomLocationAction -> { - fakeLocationStateUseCase.setRandomLocation() - flowOf(Effect.NoEffect) - } - is Action.UseRealLocationAction -> { - fakeLocationStateUseCase.stopFakeLocation() - flowOf(Effect.NoEffect) - } - is Action.CloseQuickPrivacyDisabledMessage -> { - getQuickPrivacyStateUseCase.resetQuickPrivacyDisabledMessage() - flowOf(Effect.NoEffect) - } - } - }, - singleEventProducer = { state, _, effect -> - when (effect) { - is Effect.LocationUpdatedEffect -> - SingleEvent.LocationUpdatedEvent(state.mode, effect.location) - is Effect.ErrorEffect -> SingleEvent.ErrorEvent(effect.message) - else -> null - } - } - ) - } -} diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationFragment.kt b/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationFragment.kt index 284a223..2b858e9 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationFragment.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationFragment.kt @@ -28,7 +28,9 @@ import androidx.annotation.NonNull import androidx.core.view.isVisible import androidx.core.widget.addTextChangedListener import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.snackbar.Snackbar import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout @@ -44,7 +46,6 @@ import com.mapbox.mapboxsdk.location.modes.CameraMode import com.mapbox.mapboxsdk.location.modes.RenderMode import com.mapbox.mapboxsdk.maps.MapboxMap import com.mapbox.mapboxsdk.maps.Style -import foundation.e.flowmvi.MVIView import foundation.e.privacycentralapp.DependencyContainer import foundation.e.privacycentralapp.PrivacyCentralApplication import foundation.e.privacycentralapp.R @@ -52,18 +53,13 @@ import foundation.e.privacycentralapp.common.NavToolbarFragment import foundation.e.privacycentralapp.common.initQuickPrivacySnackbar import foundation.e.privacycentralapp.databinding.FragmentFakeLocationBinding import foundation.e.privacycentralapp.domain.entities.LocationMode -import foundation.e.privacycentralapp.extensions.viewModelProviderFactoryOf -import foundation.e.privacycentralapp.features.location.FakeLocationFeature.Action +import foundation.e.privacycentralapp.features.location.FakeLocationViewModel.Action import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch -class FakeLocationFragment : - NavToolbarFragment(R.layout.fragment_fake_location), - MVIView<FakeLocationFeature.State, Action> { +class FakeLocationFragment : NavToolbarFragment(R.layout.fragment_fake_location) { private var isFirstLaunch: Boolean = true @@ -72,7 +68,7 @@ class FakeLocationFragment : } private val viewModel: FakeLocationViewModel by viewModels { - viewModelProviderFactoryOf { dependencyContainer.fakeLocationViewModelFactory.create() } + dependencyContainer.viewModelsFactory } private var _binding: FragmentFakeLocationBinding? = null @@ -87,26 +83,6 @@ class FakeLocationFragment : companion object { private const val DEBOUNCE_PERIOD = 1000L - private const val DEFAULT_INTERVAL_IN_MILLISECONDS = 1000L - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycleScope.launchWhenStarted { - viewModel.fakeLocationFeature.takeView(this, this@FakeLocationFragment) - } - lifecycleScope.launchWhenStarted { - viewModel.fakeLocationFeature.singleEvents.collect { event -> - when (event) { - is FakeLocationFeature.SingleEvent.ErrorEvent -> { - displayToast(event.error) - } - is FakeLocationFeature.SingleEvent.LocationUpdatedEvent -> { - updateLocation(event.location, event.mode) - } - } - } - } } override fun onAttach(context: Context) { @@ -146,13 +122,41 @@ class FakeLocationFragment : // Bind click listeners once map is ready. bindClickListeners() - render(viewModel.fakeLocationFeature.state.value) + render(viewModel.state.value) } } qpDisabledSnackbar = initQuickPrivacySnackbar(binding.root) { viewModel.submitAction(Action.CloseQuickPrivacyDisabledMessage) } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + render(viewModel.state.value) + viewModel.state.collect(::render) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect { event -> + when (event) { + is FakeLocationViewModel.SingleEvent.ErrorEvent -> { + displayToast(event.error) + } + is FakeLocationViewModel.SingleEvent.LocationUpdatedEvent -> { + updateLocation(event.location, event.mode) + } + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } + } } private fun getCoordinatesAfterTextChanged( @@ -231,7 +235,7 @@ class FakeLocationFragment : } @SuppressLint("MissingPermission") - override fun render(state: FakeLocationFeature.State) { + private fun render(state: FakeLocationState) { if (state.showQuickPrivacyDisabledMessage) qpDisabledSnackbar?.show() else qpDisabledSnackbar?.dismiss() @@ -267,8 +271,6 @@ class FakeLocationFragment : binding.edittextLongitude.setText(state.specificLongitude?.toString()) } - override fun actions(): Flow<Action> = viewModel.actions - @SuppressLint("MissingPermission") private fun updateLocation(lastLocation: Location?, mode: LocationMode) { lastLocation?.let { location -> @@ -324,7 +326,7 @@ class FakeLocationFragment : override fun onResume() { super.onResume() - viewModel.submitAction(Action.Init) + viewModel.submitAction(Action.EnterScreen) binding.mapView.onResume() } diff --git a/app/src/main/java/foundation/e/privacycentralapp/extensions/ViewModelExtension.kt b/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationState.kt index d256219..c7bcd98 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/extensions/ViewModelExtension.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationState.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 E FOUNDATION + * Copyright (C) 2022 E FOUNDATION * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -15,14 +15,16 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -package foundation.e.privacycentralapp.extensions +package foundation.e.privacycentralapp.features.location -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider +import android.location.Location +import foundation.e.privacycentralapp.domain.entities.LocationMode -inline fun <VM : ViewModel> viewModelProviderFactoryOf( - crossinline f: () -> VM -): ViewModelProvider.Factory = object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun <T : ViewModel?> create(modelClass: Class<T>): T = f() as T -} +data class FakeLocationState( + val mode: LocationMode = LocationMode.REAL_LOCATION, + val currentLocation: Location? = null, + val specificLatitude: Float? = null, + val specificLongitude: Float? = null, + val forceRefresh: Boolean = false, + val showQuickPrivacyDisabledMessage: Boolean = false +)
\ No newline at end of file diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationViewModel.kt b/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationViewModel.kt index 4b91276..af20a72 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationViewModel.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/features/location/FakeLocationViewModel.kt @@ -17,43 +17,104 @@ package foundation.e.privacycentralapp.features.location +import android.location.Location import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import foundation.e.privacycentralapp.common.Factory +import foundation.e.privacycentralapp.domain.entities.LocationMode import foundation.e.privacycentralapp.domain.usecases.FakeLocationStateUseCase import foundation.e.privacycentralapp.domain.usecases.GetQuickPrivacyStateUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds class FakeLocationViewModel( private val getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase, private val fakeLocationStateUseCase: FakeLocationStateUseCase ) : ViewModel() { + companion object { + private val SET_SPECIFIC_LOCATION_DELAY = 200.milliseconds + } + + private val _state = MutableStateFlow(FakeLocationState()) + val state = _state.asStateFlow() - private val _actions = MutableSharedFlow<FakeLocationFeature.Action>() - val actions = _actions.asSharedFlow() + private val _singleEvents = MutableSharedFlow<SingleEvent>() + val singleEvents = _singleEvents.asSharedFlow() - val fakeLocationFeature: FakeLocationFeature by lazy { - FakeLocationFeature.create( - getQuickPrivacyStateUseCase = getQuickPrivacyStateUseCase, - fakeLocationStateUseCase = fakeLocationStateUseCase, - coroutineScope = viewModelScope - ) + private val specificLocationInputFlow = MutableSharedFlow<Action.SetSpecificLocationAction>() + + @OptIn(FlowPreview::class) + suspend fun doOnStartedState() = withContext(Dispatchers.Main) { + launch { + merge( + fakeLocationStateUseCase.configuredLocationMode.map { (mode, lat, lon) -> + _state.update { s -> s.copy( + mode = mode, + specificLatitude = lat, + specificLongitude = lon + ) } + }, + getQuickPrivacyStateUseCase.showQuickPrivacyDisabledMessage.map { + _state.update { s -> s.copy(showQuickPrivacyDisabledMessage = it) } + }, + specificLocationInputFlow + .debounce(SET_SPECIFIC_LOCATION_DELAY).map { action -> + fakeLocationStateUseCase.setSpecificLocation(action.latitude, action.longitude) + } + ).collect {} + } + + launch { + fakeLocationStateUseCase.currentLocation.collect { location -> + _singleEvents.emit(SingleEvent.LocationUpdatedEvent( + mode = _state.value.mode, + location = location + )) + } + } } - fun submitAction(action: FakeLocationFeature.Action) { - viewModelScope.launch { - _actions.emit(action) + fun submitAction(action: Action) = viewModelScope.launch { + when (action) { + is Action.EnterScreen -> fakeLocationStateUseCase.startListeningLocation() + is Action.LeaveScreen -> fakeLocationStateUseCase.stopListeningLocation() + is Action.SetSpecificLocationAction -> setSpecificLocation(action) + is Action.UseRandomLocationAction -> fakeLocationStateUseCase.setRandomLocation() + is Action.UseRealLocationAction -> + fakeLocationStateUseCase.stopFakeLocation() + is Action.CloseQuickPrivacyDisabledMessage -> + getQuickPrivacyStateUseCase.resetQuickPrivacyDisabledMessage() } } -} -class FakeLocationViewModelFactory( - private val getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase, - private val fakeLocationStateUseCase: FakeLocationStateUseCase -) : Factory<FakeLocationViewModel> { - override fun create(): FakeLocationViewModel { - return FakeLocationViewModel(getQuickPrivacyStateUseCase, fakeLocationStateUseCase) + private suspend fun setSpecificLocation(action: Action.SetSpecificLocationAction) { + specificLocationInputFlow.emit(action) + } + + sealed class SingleEvent { + data class LocationUpdatedEvent(val mode: LocationMode, val location: Location?) : SingleEvent() + data class ErrorEvent(val error: String) : SingleEvent() + } + + sealed class Action { + object EnterScreen : Action() + object LeaveScreen : Action() + object UseRealLocationAction : Action() + object UseRandomLocationAction : Action() + data class SetSpecificLocationAction( + val latitude: Float, + val longitude: Float + ) : Action() + object CloseQuickPrivacyDisabledMessage : Action() } } diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/trackers/TrackersFeature.kt b/app/src/main/java/foundation/e/privacycentralapp/features/trackers/TrackersFeature.kt deleted file mode 100644 index 25443e9..0000000 --- a/app/src/main/java/foundation/e/privacycentralapp/features/trackers/TrackersFeature.kt +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright (C) 2021 E FOUNDATION - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package foundation.e.privacycentralapp.features.trackers - -import android.util.Log -import foundation.e.flowmvi.Actor -import foundation.e.flowmvi.Reducer -import foundation.e.flowmvi.SingleEventProducer -import foundation.e.flowmvi.feature.BaseFeature -import foundation.e.privacycentralapp.domain.entities.AppWithCounts -import foundation.e.privacycentralapp.domain.entities.TrackersPeriodicStatistics -import foundation.e.privacycentralapp.domain.usecases.GetQuickPrivacyStateUseCase -import foundation.e.privacycentralapp.domain.usecases.TrackersStatisticsUseCase -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge - -// Define a state machine for Tracker feature. -class TrackersFeature( - initialState: State, - coroutineScope: CoroutineScope, - reducer: Reducer<State, Effect>, - actor: Actor<State, Action, Effect>, - singleEventProducer: SingleEventProducer<State, Action, Effect, SingleEvent> -) : BaseFeature<TrackersFeature.State, TrackersFeature.Action, TrackersFeature.Effect, TrackersFeature.SingleEvent>( - initialState, - actor, - reducer, - coroutineScope, - { message -> Log.d("TrackersFeature", message) }, - singleEventProducer -) { - data class State( - val dayStatistics: TrackersPeriodicStatistics? = null, - val monthStatistics: TrackersPeriodicStatistics? = null, - val yearStatistics: TrackersPeriodicStatistics? = null, - val apps: List<AppWithCounts>? = null, - val showQuickPrivacyDisabledMessage: Boolean = false - ) - - sealed class SingleEvent { - data class ErrorEvent(val error: String) : SingleEvent() - data class OpenAppDetailsEvent(val appDesc: AppWithCounts) : SingleEvent() - object NewStatisticsAvailableSingleEvent : SingleEvent() - } - - sealed class Action { - object InitAction : Action() - data class ClickAppAction(val packageName: String) : Action() - object FetchStatistics : Action() - object CloseQuickPrivacyDisabledMessage : Action() - } - - sealed class Effect { - object NoEffect : Effect() - data class TrackersStatisticsLoadedEffect( - val dayStatistics: TrackersPeriodicStatistics? = null, - val monthStatistics: TrackersPeriodicStatistics? = null, - val yearStatistics: TrackersPeriodicStatistics? = null - ) : Effect() - data class AvailableAppsListEffect( - val apps: List<AppWithCounts> - ) : Effect() - data class OpenAppDetailsEffect(val appDesc: AppWithCounts) : Effect() - data class ErrorEffect(val message: String) : Effect() - object NewStatisticsAvailablesEffect : Effect() - data class ShowQuickPrivacyDisabledMessageEffect(val show: Boolean) : Effect() - } - - companion object { - fun create( - initialState: State = State(), - getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase, - coroutineScope: CoroutineScope, - trackersStatisticsUseCase: TrackersStatisticsUseCase - ) = TrackersFeature( - initialState, coroutineScope, - reducer = { state, effect -> - when (effect) { - is Effect.TrackersStatisticsLoadedEffect -> state.copy( - dayStatistics = effect.dayStatistics, - monthStatistics = effect.monthStatistics, - yearStatistics = effect.yearStatistics, - ) - is Effect.AvailableAppsListEffect -> state.copy(apps = effect.apps) - - is Effect.ErrorEffect -> state - is Effect.ShowQuickPrivacyDisabledMessageEffect -> state.copy(showQuickPrivacyDisabledMessage = effect.show) - else -> state - } - }, - actor = { state, action -> - when (action) { - Action.InitAction -> merge<Effect>( - trackersStatisticsUseCase.listenUpdates().map { - Effect.NewStatisticsAvailablesEffect - }, - getQuickPrivacyStateUseCase.showQuickPrivacyDisabledMessage.map { - Effect.ShowQuickPrivacyDisabledMessageEffect(it) - }, - ) - - is Action.ClickAppAction -> flowOf( - state.apps?.find { it.packageName == action.packageName }?.let { - Effect.OpenAppDetailsEffect(it) - } ?: run { Effect.ErrorEffect("Can't find back app.") } - ) - is Action.FetchStatistics -> merge<Effect>( - flow { - trackersStatisticsUseCase.getDayMonthYearStatistics() - .let { (day, month, year) -> - emit( - Effect.TrackersStatisticsLoadedEffect( - dayStatistics = day, - monthStatistics = month, - yearStatistics = year, - ) - ) - } - }, - trackersStatisticsUseCase.getAppsWithCounts().map { - Effect.AvailableAppsListEffect(it) - } - ) - is Action.CloseQuickPrivacyDisabledMessage -> { - getQuickPrivacyStateUseCase.resetQuickPrivacyDisabledMessage() - flowOf(Effect.NoEffect) - } - } - }, - singleEventProducer = { _, _, effect -> - when (effect) { - is Effect.ErrorEffect -> SingleEvent.ErrorEvent(effect.message) - is Effect.OpenAppDetailsEffect -> SingleEvent.OpenAppDetailsEvent(effect.appDesc) - is Effect.NewStatisticsAvailablesEffect -> SingleEvent.NewStatisticsAvailableSingleEvent - else -> null - } - } - ) - } -} diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/trackers/TrackersFragment.kt b/app/src/main/java/foundation/e/privacycentralapp/features/trackers/TrackersFragment.kt index f6a031b..4992230 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/features/trackers/TrackersFragment.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/features/trackers/TrackersFragment.kt @@ -24,10 +24,11 @@ import androidx.core.view.isVisible import androidx.fragment.app.commit import androidx.fragment.app.replace import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.snackbar.Snackbar -import foundation.e.flowmvi.MVIView import foundation.e.privacycentralapp.DependencyContainer import foundation.e.privacycentralapp.PrivacyCentralApplication import foundation.e.privacycentralapp.R @@ -38,22 +39,17 @@ import foundation.e.privacycentralapp.common.initQuickPrivacySnackbar import foundation.e.privacycentralapp.databinding.FragmentTrackersBinding import foundation.e.privacycentralapp.databinding.TrackersItemGraphBinding import foundation.e.privacycentralapp.domain.entities.TrackersPeriodicStatistics -import foundation.e.privacycentralapp.extensions.viewModelProviderFactoryOf import foundation.e.privacycentralapp.features.trackers.apptrackers.AppTrackersFragment -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch class TrackersFragment : - NavToolbarFragment(R.layout.fragment_trackers), - MVIView<TrackersFeature.State, TrackersFeature.Action> { + NavToolbarFragment(R.layout.fragment_trackers) { private val dependencyContainer: DependencyContainer by lazy { (this.requireActivity().application as PrivacyCentralApplication).dependencyContainer } - private val viewModel: TrackersViewModel by viewModels { - viewModelProviderFactoryOf { dependencyContainer.trackersViewModelFactory.create() } - } + private val viewModel: TrackersViewModel by viewModels { dependencyContainer.viewModelsFactory } private var _binding: FragmentTrackersBinding? = null private val binding get() = _binding!! @@ -63,41 +59,6 @@ class TrackersFragment : private var yearGraphHolder: GraphHolder? = null private var qpDisabledSnackbar: Snackbar? = null - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycleScope.launchWhenStarted { - viewModel.trackersFeature.takeView(this, this@TrackersFragment) - } - lifecycleScope.launchWhenStarted { - viewModel.trackersFeature.singleEvents.collect { event -> - when (event) { - is TrackersFeature.SingleEvent.ErrorEvent -> { - displayToast(event.error) - } - is TrackersFeature.SingleEvent.OpenAppDetailsEvent -> { - requireActivity().supportFragmentManager.commit { - replace<AppTrackersFragment>(R.id.container, args = AppTrackersFragment.buildArgs(event.appDesc.label.toString(), event.appDesc.packageName)) - setReorderingAllowed(true) - addToBackStack("apptrackers") - } - } - is TrackersFeature.SingleEvent.NewStatisticsAvailableSingleEvent -> { - viewModel.submitAction(TrackersFeature.Action.FetchStatistics) - } - } - } - } - - lifecycleScope.launchWhenStarted { - viewModel.submitAction(TrackersFeature.Action.InitAction) - } - } - - private fun displayToast(message: String) { - Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT) - .show() - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -112,24 +73,63 @@ class TrackersFragment : setHasFixedSize(true) adapter = AppsAdapter(R.layout.trackers_item_app) { packageName -> viewModel.submitAction( - TrackersFeature.Action.ClickAppAction(packageName) + TrackersViewModel.Action.ClickAppAction(packageName) ) } } qpDisabledSnackbar = initQuickPrivacySnackbar(binding.root) { - viewModel.submitAction(TrackersFeature.Action.CloseQuickPrivacyDisabledMessage) + viewModel.submitAction(TrackersViewModel.Action.CloseQuickPrivacyDisabledMessage) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + render(viewModel.state.value) + viewModel.state.collect(::render) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect { event -> + when (event) { + is TrackersViewModel.SingleEvent.ErrorEvent -> { + displayToast(event.error) + } + is TrackersViewModel.SingleEvent.OpenAppDetailsEvent -> { + requireActivity().supportFragmentManager.commit { + replace<AppTrackersFragment>( + R.id.container, + args = AppTrackersFragment.buildArgs( + event.appDesc.label.toString(), + event.appDesc.packageName, + event.appDesc.uid + ) + ) + setReorderingAllowed(true) + addToBackStack("apptrackers") + } + } + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } } } - override fun onResume() { - super.onResume() - viewModel.submitAction(TrackersFeature.Action.FetchStatistics) + private fun displayToast(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT) + .show() } override fun getTitle() = getString(R.string.trackers_title) - override fun render(state: TrackersFeature.State) { + private fun render(state: TrackersState) { if (state.showQuickPrivacyDisabledMessage) qpDisabledSnackbar?.show() else qpDisabledSnackbar?.dismiss() @@ -162,8 +162,6 @@ class TrackersFragment : } } - override fun actions(): Flow<TrackersFeature.Action> = viewModel.actions - override fun onDestroyView() { super.onDestroyView() qpDisabledSnackbar = null @@ -171,6 +169,5 @@ class TrackersFragment : monthGraphHolder = null yearGraphHolder = null _binding = null - } } diff --git a/app/src/main/java/foundation/e/privacycentralapp/main/MainViewModel.kt b/app/src/main/java/foundation/e/privacycentralapp/features/trackers/TrackersState.kt index 7e758b7..f51ff18 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/main/MainViewModel.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/features/trackers/TrackersState.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 E FOUNDATION + * Copyright (C) 2022 E FOUNDATION * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -15,8 +15,15 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -package foundation.e.privacycentralapp.main +package foundation.e.privacycentralapp.features.trackers -import androidx.lifecycle.ViewModel +import foundation.e.privacycentralapp.domain.entities.AppWithCounts +import foundation.e.privacycentralapp.domain.entities.TrackersPeriodicStatistics -class MainViewModel : ViewModel() +data class TrackersState( + val dayStatistics: TrackersPeriodicStatistics? = null, + val monthStatistics: TrackersPeriodicStatistics? = null, + val yearStatistics: TrackersPeriodicStatistics? = null, + val apps: List<AppWithCounts>? = null, + val showQuickPrivacyDisabledMessage: Boolean = false +)
\ No newline at end of file diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/trackers/TrackersViewModel.kt b/app/src/main/java/foundation/e/privacycentralapp/features/trackers/TrackersViewModel.kt index 4140381..f49152e 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/features/trackers/TrackersViewModel.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/features/trackers/TrackersViewModel.kt @@ -19,45 +19,74 @@ package foundation.e.privacycentralapp.features.trackers import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import foundation.e.privacycentralapp.common.Factory +import foundation.e.privacycentralapp.domain.entities.AppWithCounts import foundation.e.privacycentralapp.domain.usecases.GetQuickPrivacyStateUseCase import foundation.e.privacycentralapp.domain.usecases.TrackersStatisticsUseCase +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class TrackersViewModel( private val getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase, private val trackersStatisticsUseCase: TrackersStatisticsUseCase ) : ViewModel() { - private val _actions = MutableSharedFlow<TrackersFeature.Action>() - val actions = _actions.asSharedFlow() + private val _state = MutableStateFlow(TrackersState()) + val state = _state.asStateFlow() - val trackersFeature: TrackersFeature by lazy { - TrackersFeature.create( - coroutineScope = viewModelScope, - getQuickPrivacyStateUseCase = getQuickPrivacyStateUseCase, - trackersStatisticsUseCase = trackersStatisticsUseCase - ) + private val _singleEvents = MutableSharedFlow<SingleEvent>() + val singleEvents = _singleEvents.asSharedFlow() + + suspend fun doOnStartedState() = withContext(Dispatchers.IO) { + merge( + getQuickPrivacyStateUseCase.showQuickPrivacyDisabledMessage.map { + _state.update { s -> s.copy(showQuickPrivacyDisabledMessage = it) } + }, + trackersStatisticsUseCase.listenUpdates().map { + trackersStatisticsUseCase.getDayMonthYearStatistics() + .let { (day, month, year) -> + _state.update { s -> s.copy( + dayStatistics = day, + monthStatistics = month, + yearStatistics = year + ) } + } + }, + trackersStatisticsUseCase.getAppsWithCounts().map { + _state.update { s -> s.copy(apps = it) } + } + ).collect {} } - fun submitAction(action: TrackersFeature.Action) { - viewModelScope.launch { - _actions.emit(action) + fun submitAction(action: Action) = viewModelScope.launch { + when (action) { + is Action.ClickAppAction -> actionClickApp(action) + is Action.CloseQuickPrivacyDisabledMessage -> { + getQuickPrivacyStateUseCase.resetQuickPrivacyDisabledMessage() + } } } -} -class TrackersViewModelFactory( - private val getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase, - private val trackersStatisticsUseCase: TrackersStatisticsUseCase -) : - Factory<TrackersViewModel> { - override fun create(): TrackersViewModel { - return TrackersViewModel( - getQuickPrivacyStateUseCase, - trackersStatisticsUseCase - ) + suspend private fun actionClickApp(action: Action.ClickAppAction) { + state.value.apps?.find { it.packageName == action.packageName }?.let { + _singleEvents.emit(SingleEvent.OpenAppDetailsEvent(it)) + } + } + + sealed class SingleEvent { + data class ErrorEvent(val error: String) : SingleEvent() + data class OpenAppDetailsEvent(val appDesc: AppWithCounts) : SingleEvent() + } + + sealed class Action { + data class ClickAppAction(val packageName: String) : Action() + object CloseQuickPrivacyDisabledMessage : Action() } } diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/trackers/apptrackers/AppTrackersFeature.kt b/app/src/main/java/foundation/e/privacycentralapp/features/trackers/apptrackers/AppTrackersFeature.kt deleted file mode 100644 index f6d7d67..0000000 --- a/app/src/main/java/foundation/e/privacycentralapp/features/trackers/apptrackers/AppTrackersFeature.kt +++ /dev/null @@ -1,242 +0,0 @@ -/* - * Copyright (C) 2021 E FOUNDATION - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package foundation.e.privacycentralapp.features.trackers.apptrackers - -import android.net.Uri -import android.util.Log -import foundation.e.flowmvi.Actor -import foundation.e.flowmvi.Reducer -import foundation.e.flowmvi.SingleEventProducer -import foundation.e.flowmvi.feature.BaseFeature -import foundation.e.privacycentralapp.R -import foundation.e.privacycentralapp.domain.usecases.GetQuickPrivacyStateUseCase -import foundation.e.privacycentralapp.domain.usecases.TrackersStateUseCase -import foundation.e.privacycentralapp.domain.usecases.TrackersStatisticsUseCase -import foundation.e.privacymodules.permissions.data.ApplicationDescription -import foundation.e.privacymodules.trackers.Tracker -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge - -// Define a state machine for Tracker feature. -class AppTrackersFeature( - initialState: State, - coroutineScope: CoroutineScope, - reducer: Reducer<State, Effect>, - actor: Actor<State, Action, Effect>, - singleEventProducer: SingleEventProducer<State, Action, Effect, SingleEvent> -) : BaseFeature<AppTrackersFeature.State, AppTrackersFeature.Action, AppTrackersFeature.Effect, AppTrackersFeature.SingleEvent>( - initialState, - actor, - reducer, - coroutineScope, - { message -> Log.d("TrackersFeature", message) }, - singleEventProducer -) { - data class State( - val appDesc: ApplicationDescription? = null, - val isBlockingActivated: Boolean = false, - val trackers: List<Tracker>? = null, - val whitelist: List<String>? = null, - val leaked: Int = 0, - val blocked: Int = 0, - val isQuickPrivacyEnabled: Boolean = false, - val showQuickPrivacyDisabledMessage: Boolean = false, - ) { - fun getTrackersStatus(): List<Pair<Tracker, Boolean>>? { - if (trackers != null && whitelist != null) { - return trackers.map { it to (it.id !in whitelist) } - } else { - return null - } - } - - fun getTrackersCount() = trackers?.size ?: 0 - fun getBlockedTrackersCount(): Int = if (isQuickPrivacyEnabled && isBlockingActivated) - getTrackersCount() - (whitelist?.size ?: 0) - else 0 - } - - sealed class SingleEvent { - data class ErrorEvent(val error: Any) : SingleEvent() - object NewStatisticsAvailableSingleEvent : SingleEvent() - data class OpenUrlEvent(val url: Uri) : SingleEvent() - } - - sealed class Action { - data class InitAction(val packageName: String) : Action() - data class BlockAllToggleAction(val isBlocked: Boolean) : Action() - data class ToggleTrackerAction(val tracker: Tracker, val isBlocked: Boolean) : Action() - data class ClickTracker(val tracker: Tracker) : Action() - object FetchStatistics : Action() - object CloseQuickPrivacyDisabledMessage : Action() - } - - sealed class Effect { - object NoEffect : Effect() - data class ErrorEffect(val message: Any) : Effect() - data class SetAppEffect(val appDesc: ApplicationDescription) : Effect() - data class AppTrackersBlockingActivatedEffect(val isBlockingActivated: Boolean) : Effect() - data class AvailableTrackersListEffect( - val trackers: List<Tracker>, - val blocked: Int, - val leaked: Int - ) : Effect() - data class TrackersWhitelistUpdateEffect(val whitelist: List<String>) : Effect() - object NewStatisticsAvailablesEffect : Effect() - data class QuickPrivacyUpdatedEffect(val enabled: Boolean) : Effect() - data class OpenUrlEffect(val url: Uri) : Effect() - data class ShowQuickPrivacyDisabledMessageEffect(val show: Boolean) : Effect() - } - - companion object { - - private const val exodusBaseUrl = "https://reports.exodus-privacy.eu.org/fr/trackers/" - fun create( - initialState: State = State(), - coroutineScope: CoroutineScope, - trackersStateUseCase: TrackersStateUseCase, - trackersStatisticsUseCase: TrackersStatisticsUseCase, - getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase - ) = AppTrackersFeature( - initialState, coroutineScope, - reducer = { state, effect -> - when (effect) { - is Effect.SetAppEffect -> state.copy(appDesc = effect.appDesc) - is Effect.AvailableTrackersListEffect -> state.copy( - trackers = effect.trackers, - leaked = effect.leaked, - blocked = effect.blocked - ) - - is Effect.AppTrackersBlockingActivatedEffect -> - state.copy(isBlockingActivated = effect.isBlockingActivated) - - is Effect.TrackersWhitelistUpdateEffect -> - state.copy(whitelist = effect.whitelist) - is Effect.QuickPrivacyUpdatedEffect -> - state.copy(isQuickPrivacyEnabled = effect.enabled) - is Effect.ShowQuickPrivacyDisabledMessageEffect -> state.copy(showQuickPrivacyDisabledMessage = effect.show) - is Effect.ErrorEffect -> state - else -> state - } - }, - actor = { state, action -> - when (action) { - is Action.InitAction -> - trackersStateUseCase - .getApplicationDescription(action.packageName)?.let { appDesc -> - merge<Effect>( - flow { - emit(Effect.SetAppEffect(appDesc)) - emit( - Effect.AppTrackersBlockingActivatedEffect( - !trackersStateUseCase.isWhitelisted(appDesc.uid) - ) - ) - emit( - Effect.TrackersWhitelistUpdateEffect( - trackersStateUseCase.getTrackersWhitelistIds(appDesc.uid) - ) - ) - }, - trackersStatisticsUseCase.listenUpdates().map { - Effect.NewStatisticsAvailablesEffect - }, - getQuickPrivacyStateUseCase.quickPrivacyEnabledFlow.map { - Effect.QuickPrivacyUpdatedEffect(it) - }, - getQuickPrivacyStateUseCase.showQuickPrivacyDisabledMessage.map { - Effect.ShowQuickPrivacyDisabledMessageEffect(it) - }, - ) - } ?: flowOf(Effect.ErrorEffect(R.string.apptrackers_error_no_app)) - - is Action.BlockAllToggleAction -> - state.appDesc?.uid?.let { appUid -> - flow { - trackersStateUseCase.toggleAppWhitelist(appUid, !action.isBlocked) - - emit( - Effect.AppTrackersBlockingActivatedEffect( - !trackersStateUseCase.isWhitelisted(appUid) - ) - ) - } - } ?: run { flowOf(Effect.ErrorEffect("No appDesc.")) } - is Action.ToggleTrackerAction -> { - if (state.isBlockingActivated) { - state.appDesc?.uid?.let { appUid -> - flow { - trackersStateUseCase.blockTracker( - appUid, - action.tracker, - action.isBlocked - ) - emit( - Effect.TrackersWhitelistUpdateEffect( - trackersStateUseCase.getTrackersWhitelistIds(appUid) - ) - ) - } - } ?: run { flowOf(Effect.ErrorEffect("No appDesc.")) } - } else flowOf(Effect.NoEffect) - } - is Action.ClickTracker -> { - flowOf( - action.tracker.exodusId?.let { - try { - Effect.OpenUrlEffect(Uri.parse(exodusBaseUrl + it)) - } catch (e: Exception) { - Effect.ErrorEffect("Invalid Url") - } - } ?: Effect.NoEffect - ) - } - is Action.FetchStatistics -> flowOf( - state.appDesc?.uid?.let { - val (blocked, leaked) = trackersStatisticsUseCase.getCalls(it) - - Effect.AvailableTrackersListEffect( - trackers = trackersStatisticsUseCase.getTrackers(it), - leaked = leaked, - blocked = blocked, - ) - } ?: Effect.ErrorEffect("No appDesc.") - ) - is Action.CloseQuickPrivacyDisabledMessage -> { - getQuickPrivacyStateUseCase.resetQuickPrivacyDisabledMessage() - flowOf(Effect.NoEffect) - } - } - }, - singleEventProducer = { _, _, effect -> - when (effect) { - is Effect.ErrorEffect -> SingleEvent.ErrorEvent(effect.message) - is Effect.NewStatisticsAvailablesEffect -> - SingleEvent.NewStatisticsAvailableSingleEvent - is Effect.OpenUrlEffect -> - SingleEvent.OpenUrlEvent(effect.url) - else -> null - } - } - ) - } -} diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/trackers/apptrackers/AppTrackersFragment.kt b/app/src/main/java/foundation/e/privacycentralapp/features/trackers/apptrackers/AppTrackersFragment.kt index efce9ff..75a9c4a 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/features/trackers/apptrackers/AppTrackersFragment.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/features/trackers/apptrackers/AppTrackersFragment.kt @@ -24,34 +24,33 @@ import android.view.View import android.widget.Toast import androidx.core.os.bundleOf import androidx.core.view.isVisible +import androidx.fragment.app.commit import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.snackbar.Snackbar -import foundation.e.flowmvi.MVIView import foundation.e.privacycentralapp.DependencyContainer import foundation.e.privacycentralapp.PrivacyCentralApplication import foundation.e.privacycentralapp.R import foundation.e.privacycentralapp.common.NavToolbarFragment import foundation.e.privacycentralapp.common.initQuickPrivacySnackbar import foundation.e.privacycentralapp.databinding.ApptrackersFragmentBinding -import foundation.e.privacycentralapp.extensions.toText -import foundation.e.privacycentralapp.extensions.viewModelProviderFactoryOf -import foundation.e.privacycentralapp.features.trackers.apptrackers.AppTrackersFeature.Action -import foundation.e.privacycentralapp.features.trackers.apptrackers.AppTrackersFeature.SingleEvent -import foundation.e.privacycentralapp.features.trackers.apptrackers.AppTrackersFeature.State -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect - -class AppTrackersFragment : - NavToolbarFragment(R.layout.apptrackers_fragment), - MVIView<State, Action> { +import foundation.e.privacycentralapp.common.extensions.toText +import kotlinx.coroutines.launch + +class AppTrackersFragment : NavToolbarFragment(R.layout.apptrackers_fragment) { companion object { private val PARAM_LABEL = "PARAM_LABEL" private val PARAM_PACKAGE_NAME = "PARAM_PACKAGE_NAME" - fun buildArgs(label: String, packageName: String): Bundle = bundleOf( + + const val PARAM_APP_UID = "PARAM_APP_UID" + + fun buildArgs(label: String, packageName: String, appUid: Int): Bundle = bundleOf( PARAM_LABEL to label, - PARAM_PACKAGE_NAME to packageName + PARAM_PACKAGE_NAME to packageName, + PARAM_APP_UID to appUid ) } @@ -60,9 +59,7 @@ class AppTrackersFragment : } private val viewModel: AppTrackersViewModel by viewModels { - viewModelProviderFactoryOf { - dependencyContainer.appTrackersViewModelFactory.create() - } + dependencyContainer.viewModelsFactory } private var _binding: ApptrackersFragmentBinding? = null @@ -72,30 +69,12 @@ class AppTrackersFragment : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - lifecycleScope.launchWhenStarted { - viewModel.feature.takeView(this, this@AppTrackersFragment) - } - lifecycleScope.launchWhenStarted { - viewModel.feature.singleEvents.collect { event -> - when (event) { - is SingleEvent.ErrorEvent -> - displayToast(event.error.toText(requireContext())) - is SingleEvent.NewStatisticsAvailableSingleEvent -> { - viewModel.submitAction(Action.FetchStatistics) - } - is SingleEvent.OpenUrlEvent -> - try { - startActivity(Intent(Intent.ACTION_VIEW, event.url)) - } catch (e: ActivityNotFoundException) { - displayToast("No application to see webpages") - } - } - } - } - lifecycleScope.launchWhenStarted { - requireArguments().getString(PARAM_PACKAGE_NAME)?.let { - viewModel.submitAction(Action.InitAction(it)) + val appUid = requireArguments().getInt(PARAM_APP_UID, -1) + if (appUid == -1) { + activity?.supportFragmentManager?.commit(allowStateLoss = true) { + remove(this@AppTrackersFragment) } + return } } @@ -111,7 +90,7 @@ class AppTrackersFragment : _binding = ApptrackersFragmentBinding.bind(view) binding.blockAllToggle.setOnClickListener { - viewModel.submitAction(Action.BlockAllToggleAction(binding.blockAllToggle.isChecked)) + viewModel.submitAction(AppTrackersViewModel.Action.BlockAllToggleAction(binding.blockAllToggle.isChecked)) } binding.trackers.apply { @@ -120,23 +99,48 @@ class AppTrackersFragment : adapter = ToggleTrackersAdapter( R.layout.apptrackers_item_tracker_toggle, onToggleSwitch = { tracker, isBlocked -> - viewModel.submitAction(Action.ToggleTrackerAction(tracker, isBlocked)) + viewModel.submitAction(AppTrackersViewModel.Action.ToggleTrackerAction(tracker, isBlocked)) }, - onClickTitle = { viewModel.submitAction(Action.ClickTracker(it)) } + onClickTitle = { viewModel.submitAction(AppTrackersViewModel.Action.ClickTracker(it)) } ) } qpDisabledSnackbar = initQuickPrivacySnackbar(binding.root) { - viewModel.submitAction(Action.CloseQuickPrivacyDisabledMessage) + viewModel.submitAction(AppTrackersViewModel.Action.CloseQuickPrivacyDisabledMessage) } - } - override fun onResume() { - super.onResume() - viewModel.submitAction(Action.FetchStatistics) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect { event -> + when (event) { + is AppTrackersViewModel.SingleEvent.ErrorEvent -> + displayToast(event.error.toText(requireContext())) + is AppTrackersViewModel.SingleEvent.OpenUrl -> + try { + startActivity(Intent(Intent.ACTION_VIEW, event.url)) + } catch (e: ActivityNotFoundException) { + displayToast("No application to see webpages") + } + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + render(viewModel.state.value) + viewModel.state.collect(::render) + } + } } - override fun render(state: State) { + private fun render(state: AppTrackersState) { if (state.showQuickPrivacyDisabledMessage) qpDisabledSnackbar?.show() else qpDisabledSnackbar?.dismiss() @@ -174,8 +178,6 @@ class AppTrackersFragment : } } - override fun actions(): Flow<Action> = viewModel.actions - override fun onDestroyView() { super.onDestroyView() qpDisabledSnackbar = null diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/trackers/apptrackers/AppTrackersState.kt b/app/src/main/java/foundation/e/privacycentralapp/features/trackers/apptrackers/AppTrackersState.kt new file mode 100644 index 0000000..9a294e2 --- /dev/null +++ b/app/src/main/java/foundation/e/privacycentralapp/features/trackers/apptrackers/AppTrackersState.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.privacycentralapp.features.trackers.apptrackers + +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import foundation.e.privacymodules.trackers.Tracker + +data class AppTrackersState( + val appDesc: ApplicationDescription? = null, + val isBlockingActivated: Boolean = false, + val trackers: List<Tracker>? = null, + val whitelist: List<String>? = null, + val leaked: Int = 0, + val blocked: Int = 0, + val isQuickPrivacyEnabled: Boolean = false, + val showQuickPrivacyDisabledMessage: Boolean = false, +) { + fun getTrackersStatus(): List<Pair<Tracker, Boolean>>? { + if (trackers != null && whitelist != null) { + return trackers.map { it to (it.id !in whitelist) } + } else { + return null + } + } + + fun getTrackersCount() = trackers?.size ?: 0 + fun getBlockedTrackersCount(): Int = if (isQuickPrivacyEnabled && isBlockingActivated) + getTrackersCount() - (whitelist?.size ?: 0) + else 0 +}
\ No newline at end of file diff --git a/app/src/main/java/foundation/e/privacycentralapp/features/trackers/apptrackers/AppTrackersViewModel.kt b/app/src/main/java/foundation/e/privacycentralapp/features/trackers/apptrackers/AppTrackersViewModel.kt index 995aa80..eef75a4 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/features/trackers/apptrackers/AppTrackersViewModel.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/features/trackers/apptrackers/AppTrackersViewModel.kt @@ -17,48 +17,120 @@ package foundation.e.privacycentralapp.features.trackers.apptrackers +import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import foundation.e.privacycentralapp.common.Factory import foundation.e.privacycentralapp.domain.usecases.GetQuickPrivacyStateUseCase import foundation.e.privacycentralapp.domain.usecases.TrackersStateUseCase import foundation.e.privacycentralapp.domain.usecases.TrackersStatisticsUseCase +import foundation.e.privacymodules.trackers.Tracker +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class AppTrackersViewModel( + private val appUid: Int, private val trackersStateUseCase: TrackersStateUseCase, private val trackersStatisticsUseCase: TrackersStatisticsUseCase, private val getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase ) : ViewModel() { + companion object { + private const val exodusBaseUrl = "https://reports.exodus-privacy.eu.org/fr/trackers/" + } + + private val _state = MutableStateFlow(AppTrackersState()) + val state = _state.asStateFlow() + + private val _singleEvents = MutableSharedFlow<SingleEvent>() + val singleEvents = _singleEvents.asSharedFlow() - private val _actions = MutableSharedFlow<AppTrackersFeature.Action>() - val actions = _actions.asSharedFlow() + init { + viewModelScope.launch(Dispatchers.IO) { + _state.update { it.copy( + appDesc = trackersStateUseCase.getApplicationDescription(appUid), + isBlockingActivated = !trackersStateUseCase.isWhitelisted(appUid), + whitelist = trackersStateUseCase.getTrackersWhitelistIds(appUid), + ) } + } + } - val feature: AppTrackersFeature by lazy { - AppTrackersFeature.create( - coroutineScope = viewModelScope, - trackersStateUseCase = trackersStateUseCase, - trackersStatisticsUseCase = trackersStatisticsUseCase, - getQuickPrivacyStateUseCase = getQuickPrivacyStateUseCase, - ) + suspend fun doOnStartedState() = withContext(Dispatchers.IO) { + merge( + getQuickPrivacyStateUseCase.quickPrivacyEnabledFlow.map { + _state.update { s -> s.copy(isQuickPrivacyEnabled = it) } + }, + getQuickPrivacyStateUseCase.showQuickPrivacyDisabledMessage.map { + _state.update { s -> s.copy(showQuickPrivacyDisabledMessage = it) } + }, + trackersStatisticsUseCase.listenUpdates().map { fetchStatistics() } + ).collect { } } - fun submitAction(action: AppTrackersFeature.Action) { - viewModelScope.launch { - _actions.emit(action) + fun submitAction(action: Action) = viewModelScope.launch { + when (action) { + is Action.BlockAllToggleAction -> blockAllToggleAction(action) + is Action.ToggleTrackerAction -> toggleTrackerAction(action) + is Action.ClickTracker ->actionClickTracker(action) + is Action.CloseQuickPrivacyDisabledMessage -> + getQuickPrivacyStateUseCase.resetQuickPrivacyDisabledMessage() + } + } + + private suspend fun blockAllToggleAction(action: Action.BlockAllToggleAction) + = withContext(Dispatchers.IO) { + trackersStateUseCase.toggleAppWhitelist(appUid, !action.isBlocked) + _state.update { it.copy( + isBlockingActivated = !trackersStateUseCase.isWhitelisted(appUid) + ) } + } + + private suspend fun toggleTrackerAction(action: Action.ToggleTrackerAction) + = withContext(Dispatchers.IO) { + if (state.value.isBlockingActivated) { + trackersStateUseCase.blockTracker(appUid, action.tracker, action.isBlocked) + _state.update { it.copy( + whitelist = trackersStateUseCase.getTrackersWhitelistIds(appUid) + ) } } } -} -class AppTrackersViewModelFactory( - private val trackersStateUseCase: TrackersStateUseCase, - private val trackersStatisticsUseCase: TrackersStatisticsUseCase, - private val getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase -) : - Factory<AppTrackersViewModel> { - override fun create(): AppTrackersViewModel { - return AppTrackersViewModel(trackersStateUseCase, trackersStatisticsUseCase, getQuickPrivacyStateUseCase) + private suspend fun actionClickTracker(action: Action.ClickTracker) + = withContext(Dispatchers.IO) { + action.tracker.exodusId?.let { + try { + _singleEvents.emit(SingleEvent.OpenUrl( + Uri.parse(exodusBaseUrl + it) + )) + } catch (e: Exception) {} + } + } + + private fun fetchStatistics() { + val (blocked, leaked) = trackersStatisticsUseCase.getCalls(appUid) + return _state.update { s -> s.copy( + trackers = trackersStatisticsUseCase.getTrackers(appUid), + leaked = leaked, + blocked = blocked, + ) } + } + + + sealed class SingleEvent { + data class ErrorEvent(val error: Any) : SingleEvent() + data class OpenUrl(val url: Uri) : SingleEvent() + } + + sealed class Action { + data class BlockAllToggleAction(val isBlocked: Boolean) : Action() + data class ToggleTrackerAction(val tracker: Tracker, val isBlocked: Boolean) : Action() + data class ClickTracker(val tracker: Tracker) : Action() + object CloseQuickPrivacyDisabledMessage : Action() } } diff --git a/app/src/main/java/foundation/e/privacycentralapp/main/MainActivity.kt b/app/src/main/java/foundation/e/privacycentralapp/main/MainActivity.kt index e1ccae8..63ec27f 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/main/MainActivity.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/main/MainActivity.kt @@ -30,7 +30,6 @@ import foundation.e.privacycentralapp.features.dashboard.DashboardFragment import foundation.e.privacycentralapp.features.trackers.TrackersFragment import kotlinx.coroutines.FlowPreview -@FlowPreview open class MainActivity : FragmentActivity(R.layout.activity_main) { override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) diff --git a/app/src/main/java/foundation/e/privacycentralapp/widget/Widget.kt b/app/src/main/java/foundation/e/privacycentralapp/widget/Widget.kt index 048b58c..62e279f 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/widget/Widget.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/widget/Widget.kt @@ -26,6 +26,7 @@ import foundation.e.privacycentralapp.domain.usecases.TrackersStatisticsUseCase import foundation.e.privacycentralapp.widget.State import foundation.e.privacycentralapp.widget.render import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.GlobalScope @@ -49,7 +50,7 @@ import java.time.temporal.ChronoUnit * Implementation of App Widget functionality. */ class Widget : AppWidgetProvider() { - @FlowPreview + override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, @@ -66,7 +67,6 @@ class Widget : AppWidgetProvider() { // Enter relevant functionality for when the last widget is disabled } - @FlowPreview companion object { private var updateWidgetJob: Job? = null @@ -75,6 +75,7 @@ class Widget : AppWidgetProvider() { private const val DARK_TEXT_KEY = "foundation.e.blisslauncher.WIDGET_OPTION_DARK_TEXT" var isDarkText = false + @OptIn(FlowPreview::class) private fun initState( getPrivacyStateUseCase: GetQuickPrivacyStateUseCase, trackersStatisticsUseCase: TrackersStatisticsUseCase, @@ -120,6 +121,7 @@ class Widget : AppWidgetProvider() { ) } + @OptIn(DelicateCoroutinesApi::class) fun startListening( appContext: Context, getPrivacyStateUseCase: GetQuickPrivacyStateUseCase, @@ -140,7 +142,7 @@ class Widget : AppWidgetProvider() { } } - @FlowPreview + override fun onAppWidgetOptionsChanged( context: Context, appWidgetManager: AppWidgetManager, diff --git a/app/src/main/java/foundation/e/privacycentralapp/widget/WidgetUI.kt b/app/src/main/java/foundation/e/privacycentralapp/widget/WidgetUI.kt index f95083e..7b8ceb4 100644 --- a/app/src/main/java/foundation/e/privacycentralapp/widget/WidgetUI.kt +++ b/app/src/main/java/foundation/e/privacycentralapp/widget/WidgetUI.kt @@ -29,10 +29,9 @@ import foundation.e.privacycentralapp.R import foundation.e.privacycentralapp.Widget import foundation.e.privacycentralapp.Widget.Companion.isDarkText import foundation.e.privacycentralapp.domain.entities.QuickPrivacyState -import foundation.e.privacycentralapp.extensions.dpToPxF +import foundation.e.privacycentralapp.common.extensions.dpToPxF import foundation.e.privacycentralapp.main.MainActivity import foundation.e.privacycentralapp.widget.WidgetCommandReceiver.Companion.ACTION_TOGGLE_PRIVACY -import kotlinx.coroutines.FlowPreview data class State( val quickPrivacyState: QuickPrivacyState = QuickPrivacyState.DISABLED, @@ -43,7 +42,7 @@ data class State( val activeTrackersCount: Int = 0, ) -@FlowPreview + fun render( context: Context, state: State, @@ -266,7 +265,6 @@ private const val REQUEST_CODE_TOGGLE = 2 private const val REQUEST_CODE_TRACKERS = 3 private const val REQUEST_CODE_HIGHLIGHT = 100 -@FlowPreview fun applyDarkText(context: Context, state: State, views: RemoteViews) { views.apply { listOf( diff --git a/app/src/main/res/layout/fragment_trackers.xml b/app/src/main/res/layout/fragment_trackers.xml index 9828215..b6d5b7b 100644 --- a/app/src/main/res/layout/fragment_trackers.xml +++ b/app/src/main/res/layout/fragment_trackers.xml @@ -1,13 +1,13 @@ <?xml version="1.0" encoding="utf-8"?> <layout> -<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" +<androidx.coordinatorlayout.widget.CoordinatorLayout + xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:background="@color/background" android:layout_height="match_parent" android:layout_width="match_parent" > - <include layout="@layout/topbar" /> <androidx.core.widget.NestedScrollView @@ -15,73 +15,74 @@ android:layout_width="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" > - <LinearLayout - android:layout_height="match_parent" + +<LinearLayout + android:layout_height="match_parent" + android:layout_width="match_parent" + android:orientation="vertical" + > + <LinearLayout + android:layout_height="match_parent" + android:layout_width="match_parent" + android:orientation="vertical" + > + <TextView + android:id="@+id/trackers_info" + android:layout_height="wrap_content" android:layout_width="match_parent" - android:orientation="vertical" - > - <LinearLayout - android:layout_height="match_parent" - android:layout_width="match_parent" - android:orientation="vertical" - > - <TextView - android:id="@+id/trackers_info" - android:layout_height="wrap_content" - android:layout_width="match_parent" - android:paddingTop="16dp" - android:paddingHorizontal="16dp" - android:lineSpacingExtra="5sp" - android:text="@string/trackers_info" - /> - <TextView - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="@string/graph_subtitle" - android:textColor="@color/secondary_text" - android:textSize="14sp" - android:paddingTop="24dp" - android:paddingHorizontal="16dp" - /> + android:paddingTop="16dp" + android:paddingHorizontal="16dp" + android:lineSpacingExtra="5sp" + android:text="@string/trackers_info" + /> + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/graph_subtitle" + android:textColor="@color/secondary_text" + android:textSize="14sp" + android:paddingTop="24dp" + android:paddingHorizontal="16dp" + /> + + <include layout="@layout/trackers_item_graph" + android:id="@+id/graph_day" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:period="@{@string/trackers_period_day}" + /> + <include layout="@layout/trackers_item_graph" + android:id="@+id/graph_month" + android:layout_marginTop="16dp" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:period="@{@string/trackers_period_month}" + /> + <include layout="@layout/trackers_item_graph" + android:id="@+id/graph_year" + android:layout_marginTop="16dp" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:period="@{@string/trackers_period_year}" + /> + <TextView + android:layout_height="wrap_content" + android:layout_width="match_parent" + android:layout_marginTop="32dp" + android:paddingTop="16dp" + android:paddingHorizontal="16dp" + android:text="@string/trackers_applist_title" + /> + </LinearLayout> + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/apps" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingTop="16dp" + android:paddingBottom="32dp" + /> +</LinearLayout> - <include layout="@layout/trackers_item_graph" - android:id="@+id/graph_day" - android:layout_width="match_parent" - android:layout_height="wrap_content" - app:period="@{@string/trackers_period_day}" - /> - <include layout="@layout/trackers_item_graph" - android:id="@+id/graph_month" - android:layout_marginTop="16dp" - android:layout_width="match_parent" - android:layout_height="wrap_content" - app:period="@{@string/trackers_period_month}" - /> - <include layout="@layout/trackers_item_graph" - android:id="@+id/graph_year" - android:layout_marginTop="16dp" - android:layout_width="match_parent" - android:layout_height="wrap_content" - app:period="@{@string/trackers_period_year}" - /> - <TextView - android:layout_height="wrap_content" - android:layout_width="match_parent" - android:layout_marginTop="32dp" - android:paddingTop="16dp" - android:paddingHorizontal="16dp" - android:text="@string/trackers_applist_title" - /> - </LinearLayout> - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/apps" - android:layout_height="wrap_content" - android:layout_width="match_parent" - android:paddingTop="16dp" - android:paddingBottom="32dp" - tools:listitem="@layout/trackers_item_app" - /> - </LinearLayout> - </androidx.core.widget.NestedScrollView> +</androidx.core.widget.NestedScrollView> </androidx.coordinatorlayout.widget.CoordinatorLayout> </layout>
\ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 980c41a..6216e07 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -30,7 +30,6 @@ <string name="dashboard_state_title_off">Tu privacidad en linea está desprotegida</string> <string name="dashboard_apps_permissions_subtitle">Gestiona tus permisos</string> <string name="apptrackers_error_quickprivacy_disabled">Habilitar la \"Privacidad rápida\" para poder activar/desactivar los rastreadores.</string> - <string name="apptrackers_trackers_count_summary">%1$d rastreadores bloqueados de %2$d rastreadores detectados</string> <string name="quick_protection_info">La \"Protección rápida\" habilita estos ajustes cuando está activada</string> <string name="learn_more">Más información</string> <string name="add_location">Añadir ubicación</string> @@ -61,7 +60,6 @@ <string name="trackers_graph_hours_period_format">HH:mm</string> <string name="trackers_graph_days_period_format">EEE d \'de\' MMMM</string> <string name="trackers_graph_months_period_format">MMMM yyyy</string> - <string name="trackers_app_trackers_counts">%1$d rastreadores bloqueados de %2$d</string> <string name="apptrackers_block_all_toggle">Rastreadores bloqueados</string> <string name="apptrackers_no_trackers_yet_block_off">Todavía no se ha detectado ningún rastreador. Si se detectan nuevos rastreadores se actualizarán aquí.</string> <string name="ipscrambling_error_quickprivacy_disabled">Habilitada la \"Privacidad rápida\" para utilizar las funcionalidades</string> diff --git a/build.gradle b/build.gradle index a6f458d..2442f01 100644 --- a/build.gradle +++ b/build.gradle @@ -4,9 +4,9 @@ import foundation.e.privacycentral.buildsrc.ReleaseType // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { ext.buildConfig = [ - 'compileSdk': 29, + 'compileSdk': 31, 'minSdk' : 26, - 'targetSdk' : 29, + 'targetSdk' : 30, 'version' : [ 'major': 1, 'minor': 1, @@ -31,7 +31,7 @@ buildscript { dependencies { classpath Libs.androidGradlePlugin - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.20" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -44,6 +44,16 @@ plugins { } allprojects { + //Support @JvmDefault, and @OptIn + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { + kotlinOptions { + freeCompilerArgs = ['-Xjvm-default=enable', '-opt-in=kotlin.RequiresOptIn'] + + + jvmTarget = "1.8" + } + } + repositories { google() mavenCentral() diff --git a/dependencies.gradle b/dependencies.gradle index 0095881..dcb9f9d 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -19,7 +19,7 @@ libs.leakCanary = "com.squareup.leakcanary:leakcanary-android:2.6" libs.truth = "com.google.truth:truth:1.1" -versions.kotlin = "1.6.0" +versions.kotlin = "1.6.10" libs.Kotlin = [ stdlib: "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$versions.kotlin", gradlePlugin: "org.jetbrains.kotlin:kotlin-gradle-plugin:$versions.kotlin", @@ -39,7 +39,7 @@ libs.AndroidX = [ coreKtx: "androidx.core:core-ktx:1.5.0-beta01", ] -versions.fragment = "1.3.3" +versions.fragment = "1.5.0" libs.AndroidX.Fragment = [ fragment: "androidx.fragment:fragment:$versions.fragment", fragmentKtx: "androidx.fragment:fragment-ktx:$versions.fragment", @@ -53,10 +53,9 @@ libs.AndroidX.Test = [ espresso: "androidx.test.espresso:espresso-core:3.3.0", ] -versions.lifecycle = "2.3.0-rc01" +versions.lifecycle = "2.5.0" libs.AndroidX.Lifecycle = [ runtime: "androidx.lifecycle:lifecycle-runtime-ktx:$versions.lifecycle", - livedata: "androidx.lifecycle:lifecycle-livedata-ktx:$versions.lifecycle", viewmodel: "androidx.lifecycle:lifecycle-viewmodel-ktx:$versions.lifecycle", ] diff --git a/flow-mvi/.gitignore b/flow-mvi/.gitignore deleted file mode 100644 index 42afabf..0000000 --- a/flow-mvi/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build
\ No newline at end of file diff --git a/flow-mvi/build.gradle b/flow-mvi/build.gradle deleted file mode 100644 index a012229..0000000 --- a/flow-mvi/build.gradle +++ /dev/null @@ -1,31 +0,0 @@ -/* -* Copyright (C) 2021 E FOUNDATION -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see <https://www.gnu.org/licenses/>. -*/ - -plugins { - id 'java-library' - id 'kotlin' -} - -java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} - -dependencies { - implementation Libs.Kotlin.stdlib - implementation Libs.Coroutines.core -} diff --git a/flow-mvi/src/main/java/foundation/e/flowmvi/Store.kt b/flow-mvi/src/main/java/foundation/e/flowmvi/Store.kt deleted file mode 100644 index 3040f3f..0000000 --- a/flow-mvi/src/main/java/foundation/e/flowmvi/Store.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (C) 2021 E FOUNDATION - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package foundation.e.flowmvi - -import kotlinx.coroutines.flow.StateFlow - -interface Store<State : Any, in Action : Any> { - val state: StateFlow<State> -} diff --git a/flow-mvi/src/main/java/foundation/e/flowmvi/Types.kt b/flow-mvi/src/main/java/foundation/e/flowmvi/Types.kt deleted file mode 100644 index 1f22a35..0000000 --- a/flow-mvi/src/main/java/foundation/e/flowmvi/Types.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (C) 2021 E FOUNDATION - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package foundation.e.flowmvi - -import kotlinx.coroutines.flow.Flow - -/** - * Actor is a function that receives the current state and the action which just happened - * and acts on it. - * - * It returns a [Flow] of Effects which then can be used in a reducer to reduce to a new state. - */ -typealias Actor<State, Action, Effect> = (state: State, action: Action) -> Flow<Effect> - -/** - * Reducer is a function that applies the effect to current state and return a new state. - * - * This function should be free from any side-effect and make sure it is idempotent. - */ -typealias Reducer<State, Effect> = (state: State, effect: Effect) -> State - -typealias SingleEventProducer<State, Action, Effect, SingleEvent> = (state: State, action: Action, effect: Effect) -> SingleEvent? - -/** - * Logger is function used for logging - */ -typealias Logger = (String) -> Unit diff --git a/flow-mvi/src/main/java/foundation/e/flowmvi/feature/BaseFeature.kt b/flow-mvi/src/main/java/foundation/e/flowmvi/feature/BaseFeature.kt deleted file mode 100644 index 1429d1a..0000000 --- a/flow-mvi/src/main/java/foundation/e/flowmvi/feature/BaseFeature.kt +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright (C) 2021 E FOUNDATION - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package foundation.e.flowmvi.feature - -import foundation.e.flowmvi.Actor -import foundation.e.flowmvi.Logger -import foundation.e.flowmvi.MVIView -import foundation.e.flowmvi.Reducer -import foundation.e.flowmvi.SingleEventProducer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -open class BaseFeature<State : Any, in Action : Any, in Effect : Any, SingleEvent : Any>( - initialState: State, - private val actor: Actor<State, Action, Effect>, - private val reducer: Reducer<State, Effect>, - private val coroutineScope: CoroutineScope, - private val defaultLogger: Logger = {}, - private val singleEventProducer: SingleEventProducer<State, Action, Effect, SingleEvent>? = null -) : - Feature<State, Action, SingleEvent> { - - private val mutex = Mutex() - - private val _state = MutableStateFlow(initialState) - override val state: StateFlow<State> = _state.asStateFlow() - - private val singleEventChannel = Channel<SingleEvent>() - override val singleEvents: Flow<SingleEvent> = singleEventChannel.receiveAsFlow() - - override fun takeView( - viewCoroutineScope: CoroutineScope, - view: MVIView<State, Action>, - initialActions: List<Action>, - logger: Logger? - ) { - viewCoroutineScope.launch { - sendStateUpdatesIntoView(this, view, logger ?: defaultLogger) - handleViewActions(this, view, initialActions, logger ?: defaultLogger) - } - } - - private fun sendStateUpdatesIntoView( - callerCoroutineScope: CoroutineScope, - view: MVIView<State, Action>, - @Suppress("UNUSED_PARAMETER") logger: Logger - ) { - state - .onEach { - view.render(it) - } - .launchIn(callerCoroutineScope) - } - - private fun handleViewActions( - coroutineScope: CoroutineScope, - view: MVIView<State, Action>, - initialActions: List<Action>, - logger: Logger - ) { - coroutineScope.launch { - view - .actions() - .onStart { - emitAll(initialActions.asFlow()) - } - .collectIntoHandler(this, logger) - } - } - - override fun addExternalActions(actions: Flow<Action>, logger: Logger?) { - coroutineScope.launch { - actions.collectIntoHandler(this, logger ?: defaultLogger) - } - } - - private suspend fun Flow<Action>.collectIntoHandler( - callerCoroutineScope: CoroutineScope, - @Suppress("UNUSED_PARAMETER") logger: Logger - ) { - onEach { action -> - callerCoroutineScope.launch { - actor.invoke(_state.value, action) - .onEach { effect -> - mutex.withLock { - val newState = reducer.invoke(_state.value, effect) - _state.value = newState - singleEventProducer?.also { - it.invoke(newState, action, effect)?.let { singleEvent -> - singleEventChannel.send( - singleEvent - ) - } - } - } - } - .launchIn(coroutineScope) - } - } - .launchIn(callerCoroutineScope) - } -} diff --git a/flow-mvi/src/main/java/foundation/e/flowmvi/feature/Feature.kt b/flow-mvi/src/main/java/foundation/e/flowmvi/feature/Feature.kt deleted file mode 100644 index bd9ca16..0000000 --- a/flow-mvi/src/main/java/foundation/e/flowmvi/feature/Feature.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (C) 2021 E FOUNDATION - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -package foundation.e.flowmvi.feature - -import foundation.e.flowmvi.Logger -import foundation.e.flowmvi.MVIView -import foundation.e.flowmvi.Store -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow - -interface Feature<State : Any, in Action : Any, SingleEvent : Any> : Store<State, Action> { - val singleEvents: Flow<SingleEvent> - - /** - * Call this method when a new [View] is ready to render the state of this MVFlow object. - * - * @param viewCoroutineScope the scope of the view. This will be used to launch a coroutine which will run listening - * to actions until this scope is cancelled. - * @param view the view that will render the state. - * @param initialActions an optional list of Actions that can be passed to introduce an initial action into the - * screen (for example, to trigger a refresh of data). - * @param logger Optional [Logger] to log events inside this MVFlow object associated with this view (but not - * others). If null, a default logger might be used. - */ - fun takeView( - viewCoroutineScope: CoroutineScope, - view: MVIView<State, Action>, - initialActions: List<Action> = emptyList(), - logger: Logger? = null - ) - - /** - * This method adds an external source of actions into the MVFlow object. - * - * This might be useful if you need to update your state based on things happening outside the [View], such as - * timers, external database updates, push notifications, etc. - * - * @param actions the flow of events. You might want to have a look at - * [kotlinx.coroutines.flow.callbackFlow]. - * @param logger Optional [Logger] to log events inside this MVFlow object associated with this external Flow (but - * not others). If null, a default logger might be used. - */ - fun addExternalActions( - actions: Flow<Action>, - logger: Logger? = null - ) -} diff --git a/gradle.properties b/gradle.properties index 896d588..2fb49e8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,4 +17,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=true -gitLabPrivateToken=""
\ No newline at end of file +gitLabPrivateToken="" diff --git a/settings.gradle b/settings.gradle index dca731c..e39b561 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,2 @@ -include ':flow-mvi' include ':app' rootProject.name = "PrivacyCentralApp"
\ No newline at end of file |