diff options
author | Guillaume Jacquart <guillaume.jacquart@hoodbrains.com> | 2023-05-09 06:00:43 +0000 |
---|---|---|
committer | Guillaume Jacquart <guillaume.jacquart@hoodbrains.com> | 2023-05-09 06:00:43 +0000 |
commit | 5a432ecde520ee039786848296e5227571404158 (patch) | |
tree | 077eafb42d5d2d18b2ffc03bc93d9a8654377774 /app/src/main/java/foundation/e/advancedprivacy/features | |
parent | a348c8196a643e4f5853587133b05e3ec2cd0faa (diff) | |
parent | a8874167f663885f2d3371801cf03681576ac817 (diff) | |
download | advanced-privacy-5a432ecde520ee039786848296e5227571404158.tar.gz |
Merge branch '1200-move_package_to_advanced_privacy' into 'main'
1200: rename everything to AdvancedPrivacy
See merge request e/os/advanced-privacy!128
Diffstat (limited to 'app/src/main/java/foundation/e/advancedprivacy/features')
17 files changed, 2316 insertions, 0 deletions
diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardFragment.kt b/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardFragment.kt new file mode 100644 index 0000000..b30935c --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardFragment.kt @@ -0,0 +1,307 @@ +/* + * 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.advancedprivacy.features.dashboard + +import android.content.Intent +import android.os.Bundle +import android.text.Html +import android.text.Html.FROM_HTML_MODE_LEGACY +import android.view.View +import android.widget.Toast +import androidx.core.content.ContextCompat.getColor +import androidx.core.os.bundleOf +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 foundation.e.advancedprivacy.AdvancedPrivacyApplication +import foundation.e.advancedprivacy.DependencyContainer +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.common.GraphHolder +import foundation.e.advancedprivacy.common.NavToolbarFragment +import foundation.e.advancedprivacy.databinding.FragmentDashboardBinding +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode +import foundation.e.advancedprivacy.domain.entities.LocationMode +import foundation.e.advancedprivacy.domain.entities.QuickPrivacyState +import foundation.e.advancedprivacy.domain.entities.TrackerMode +import foundation.e.advancedprivacy.features.dashboard.DashboardViewModel.Action +import foundation.e.advancedprivacy.features.dashboard.DashboardViewModel.SingleEvent +import foundation.e.advancedprivacy.features.internetprivacy.InternetPrivacyFragment +import foundation.e.advancedprivacy.features.location.FakeLocationFragment +import foundation.e.advancedprivacy.features.trackers.TrackersFragment +import foundation.e.advancedprivacy.features.trackers.apptrackers.AppTrackersFragment +import kotlinx.coroutines.launch + +class DashboardFragment : NavToolbarFragment(R.layout.fragment_dashboard) { + companion object { + private const val PARAM_HIGHLIGHT_INDEX = "PARAM_HIGHLIGHT_INDEX" + fun buildArgs(highlightIndex: Int): Bundle = bundleOf( + PARAM_HIGHLIGHT_INDEX to highlightIndex + ) + } + + private val dependencyContainer: DependencyContainer by lazy { + (this.requireActivity().application as AdvancedPrivacyApplication).dependencyContainer + } + + private val viewModel: DashboardViewModel by viewModels { + dependencyContainer.viewModelsFactory + } + + private var graphHolder: GraphHolder? = null + + private var _binding: FragmentDashboardBinding? = null + private val binding get() = _binding!! + + private var highlightIndexOnStart: Int? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + highlightIndexOnStart = arguments?.getInt(PARAM_HIGHLIGHT_INDEX, -1) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentDashboardBinding.bind(view) + + graphHolder = GraphHolder(binding.graph, requireContext()) + + binding.leakingAppButton.setOnClickListener { + viewModel.submitAction(Action.ShowMostLeakedApp) + } + binding.toggleTrackers.setOnClickListener { + viewModel.submitAction(Action.ToggleTrackers) + } + binding.toggleLocation.setOnClickListener { + viewModel.submitAction(Action.ToggleLocation) + } + binding.toggleIpscrambling.setOnClickListener { + viewModel.submitAction(Action.ToggleIpScrambling) + } + binding.myLocation.container.setOnClickListener { + viewModel.submitAction(Action.ShowFakeMyLocationAction) + } + binding.internetActivityPrivacy.container.setOnClickListener { + viewModel.submitAction(Action.ShowInternetActivityPrivacyAction) + } + binding.appsPermissions.container.setOnClickListener { + viewModel.submitAction(Action.ShowAppsPermissions) + } + + binding.amITracked.container.setOnClickListener { + viewModel.submitAction(Action.ShowTrackers) + } + + 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 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(), + getString(event.message, *event.args.toTypedArray()), + Toast.LENGTH_LONG + ).show() + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } + } + } + + override fun getTitle(): String { + return getString(R.string.dashboard_title) + } + + private fun render(state: DashboardState) { + binding.stateLabel.text = getString( + when (state.quickPrivacyState) { + QuickPrivacyState.DISABLED -> R.string.dashboard_state_title_off + QuickPrivacyState.FULL_ENABLED -> R.string.dashboard_state_title_on + QuickPrivacyState.ENABLED -> R.string.dashboard_state_title_custom + } + ) + + binding.stateIcon.setImageResource( + if (state.quickPrivacyState.isEnabled()) R.drawable.ic_shield_on + else R.drawable.ic_shield_off + ) + + binding.toggleTrackers.isChecked = state.trackerMode != TrackerMode.VULNERABLE + + binding.stateTrackers.text = getString( + when (state.trackerMode) { + TrackerMode.DENIED -> R.string.dashboard_state_trackers_on + TrackerMode.VULNERABLE -> R.string.dashboard_state_trackers_off + TrackerMode.CUSTOM -> R.string.dashboard_state_trackers_custom + } + ) + binding.stateTrackers.setTextColor( + getColor( + requireContext(), + if (state.trackerMode == TrackerMode.VULNERABLE) R.color.red_off + else R.color.green_valid + ) + ) + + binding.toggleLocation.isChecked = state.isLocationHidden + + binding.stateGeolocation.text = getString( + if (state.isLocationHidden) R.string.dashboard_state_geolocation_on + else R.string.dashboard_state_geolocation_off + ) + binding.stateGeolocation.setTextColor( + getColor( + requireContext(), + if (state.isLocationHidden) R.color.green_valid + else R.color.red_off + ) + ) + + binding.toggleIpscrambling.isChecked = state.ipScramblingMode.isChecked + val isLoading = state.ipScramblingMode.isLoading + + binding.stateIpAddress.text = getString( + if (state.ipScramblingMode == InternetPrivacyMode.HIDE_IP) R.string.dashboard_state_ipaddress_on + else R.string.dashboard_state_ipaddress_off + ) + + binding.stateIpAddressLoader.visibility = if (isLoading) View.VISIBLE else View.GONE + binding.stateIpAddress.visibility = if (!isLoading) View.VISIBLE else View.GONE + + binding.stateIpAddress.setTextColor( + getColor( + requireContext(), + if (state.ipScramblingMode == InternetPrivacyMode.HIDE_IP) R.color.green_valid + else R.color.red_off + ) + ) + + if (state.dayStatistics?.all { it.first == 0 && it.second == 0 } == true) { + binding.graph.visibility = View.INVISIBLE + binding.graphLegend.isVisible = false + binding.leakingAppButton.isVisible = false + binding.graphEmpty.isVisible = true + } else { + binding.graph.isVisible = true + binding.graphLegend.isVisible = true + binding.leakingAppButton.isVisible = true + binding.graphEmpty.isVisible = false + state.dayStatistics?.let { graphHolder?.data = it } + state.dayLabels?.let { graphHolder?.labels = it } + state.dayGraduations?.let { graphHolder?.graduations = it } + + binding.graphLegend.text = Html.fromHtml( + getString( + R.string.dashboard_graph_trackers_legend, + state.leakedTrackersCount?.toString() ?: "No" + ), + FROM_HTML_MODE_LEGACY + ) + + highlightIndexOnStart?.let { + binding.graph.post { + graphHolder?.highlightIndex(it) + } + highlightIndexOnStart = null + } + } + + if (state.allowedTrackersCount != null && state.trackersCount != null) { + binding.amITracked.subTitle = getString(R.string.dashboard_am_i_tracked_subtitle, state.trackersCount, state.allowedTrackersCount) + } else { + binding.amITracked.subTitle = "" + } + + binding.myLocation.subTitle = getString( + when (state.locationMode) { + LocationMode.REAL_LOCATION -> R.string.dashboard_location_subtitle_off + LocationMode.SPECIFIC_LOCATION -> R.string.dashboard_location_subtitle_specific + LocationMode.RANDOM_LOCATION -> R.string.dashboard_location_subtitle_random + } + ) + + binding.internetActivityPrivacy.subTitle = getString( + if (state.ipScramblingMode == InternetPrivacyMode.HIDE_IP) R.string.dashboard_internet_activity_privacy_subtitle_on + else R.string.dashboard_internet_activity_privacy_subtitle_off + ) + + binding.executePendingBindings() + } + + override fun onDestroyView() { + super.onDestroyView() + graphHolder = null + _binding = null + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardState.kt b/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardState.kt new file mode 100644 index 0000000..8fc8767 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardState.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.advancedprivacy.features.dashboard + +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode +import foundation.e.advancedprivacy.domain.entities.LocationMode +import foundation.e.advancedprivacy.domain.entities.QuickPrivacyState +import foundation.e.advancedprivacy.domain.entities.TrackerMode + +data class DashboardState( + val quickPrivacyState: QuickPrivacyState = QuickPrivacyState.DISABLED, + val trackerMode: TrackerMode = TrackerMode.VULNERABLE, + val isLocationHidden: Boolean = false, + val ipScramblingMode: InternetPrivacyMode = InternetPrivacyMode.REAL_IP_LOADING, + 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 dayGraduations: List<String?>? = null, +) diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardViewModel.kt b/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..d82b073 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardViewModel.kt @@ -0,0 +1,158 @@ +/* +* Copyright (C) 2023 MURENA SAS + * 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.advancedprivacy.features.dashboard + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.domain.usecases.GetQuickPrivacyStateUseCase +import foundation.e.advancedprivacy.domain.usecases.TrackersStatisticsUseCase +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +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 _state = MutableStateFlow(DashboardState()) + val state = _state.asStateFlow() + + private val _singleEvents = MutableSharedFlow<SingleEvent>() + val singleEvents = _singleEvents.asSharedFlow() + + init { + viewModelScope.launch(Dispatchers.IO) { trackersStatisticsUseCase.initAppList() } + } + + suspend fun doOnStartedState() = withContext(Dispatchers.IO) { + merge( + getPrivacyStateUseCase.quickPrivacyState.map { + _state.update { s -> s.copy(quickPrivacyState = it) } + }, + getPrivacyStateUseCase.ipScramblingMode.map { + _state.update { s -> s.copy(ipScramblingMode = it) } + }, + trackersStatisticsUseCase.listenUpdates().flatMapLatest { + fetchStatistics() + }, + getPrivacyStateUseCase.trackerMode.map { + _state.update { s -> s.copy(trackerMode = it) } + }, + getPrivacyStateUseCase.isLocationHidden.map { + _state.update { s -> s.copy(isLocationHidden = it) } + }, + getPrivacyStateUseCase.locationMode.map { + _state.update { s -> s.copy(locationMode = it) } + }, + getPrivacyStateUseCase.otherVpnRunning.map { + _singleEvents.emit( + SingleEvent.ToastMessageSingleEvent( + R.string.ipscrambling_error_always_on_vpn_already_running, + listOf(it.label ?: "") + ) + ) + } + ).collect {} + } + + fun submitAction(action: Action) = viewModelScope.launch { + when (action) { + is Action.ToggleTrackers -> { + getPrivacyStateUseCase.toggleTrackers() + // Add delay here to prevent race condition with trackers state. + delay(200) + fetchStatistics().first() + } + is Action.ToggleLocation -> getPrivacyStateUseCase.toggleLocation() + is Action.ToggleIpScrambling -> getPrivacyStateUseCase.toggleIpScrambling() + 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.ShowMostLeakedApp -> actionShowMostLeakedApp() + } + } + + 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, + dayGraduations = dayStatistics.graduations, + leakedTrackersCount = dayStatistics.trackersCount, + trackersCount = trackersCount, + allowedTrackersCount = nonBlockedTrackersCount + ) + } + } + } + } + + 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( + @StringRes val message: Int, + val args: List<Any> = emptyList() + ) : SingleEvent() + } + + sealed class Action { + object ToggleTrackers : Action() + object ToggleLocation : Action() + object ToggleIpScrambling : Action() + object ShowFakeMyLocationAction : Action() + object ShowInternetActivityPrivacyAction : Action() + object ShowAppsPermissions : Action() + object ShowTrackers : Action() + object ShowMostLeakedApp : Action() + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyFragment.kt b/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyFragment.kt new file mode 100644 index 0000000..07da82a --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyFragment.kt @@ -0,0 +1,201 @@ +/* + * 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.advancedprivacy.features.internetprivacy + +import android.os.Bundle +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import foundation.e.advancedprivacy.AdvancedPrivacyApplication +import foundation.e.advancedprivacy.DependencyContainer +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.common.NavToolbarFragment +import foundation.e.advancedprivacy.common.ToggleAppsAdapter +import foundation.e.advancedprivacy.common.setToolTipForAsterisk +import foundation.e.advancedprivacy.databinding.FragmentInternetActivityPolicyBinding +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode +import kotlinx.coroutines.launch +import java.util.Locale + +class InternetPrivacyFragment : NavToolbarFragment(R.layout.fragment_internet_activity_policy) { + + private val dependencyContainer: DependencyContainer by lazy { + (this.requireActivity().application as AdvancedPrivacyApplication).dependencyContainer + } + + private val viewModel: InternetPrivacyViewModel by viewModels { + dependencyContainer.viewModelsFactory + } + + private var _binding: FragmentInternetActivityPolicyBinding? = null + private val binding get() = _binding!! + + private fun displayToast(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT) + .show() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentInternetActivityPolicyBinding.bind(view) + + binding.apps.apply { + layoutManager = LinearLayoutManager(requireContext()) + setHasFixedSize(true) + adapter = ToggleAppsAdapter(R.layout.ipscrambling_item_app_toggle) { packageName -> + viewModel.submitAction( + InternetPrivacyViewModel.Action.ToggleAppIpScrambled(packageName) + ) + } + } + + binding.radioUseRealIp.radiobutton.setOnClickListener { + viewModel.submitAction(InternetPrivacyViewModel.Action.UseRealIPAction) + } + + binding.radioUseHiddenIp.radiobutton.setOnClickListener { + viewModel.submitAction(InternetPrivacyViewModel.Action.UseHiddenIPAction) + } + + setToolTipForAsterisk( + textView = binding.ipscramblingSelectApps, + textId = R.string.ipscrambling_select_app, + tooltipTextId = R.string.ipscrambling_app_list_infos + ) + + binding.ipscramblingSelectLocation.apply { + adapter = ArrayAdapter( + requireContext(), android.R.layout.simple_spinner_item, + viewModel.availablesLocationsIds.map { + if (it == "") { + getString(R.string.ipscrambling_any_location) + } else { + Locale("", it).displayCountry + } + } + ).apply { + setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + } + + onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parentView: AdapterView<*>, + selectedItemView: View?, + position: Int, + id: Long + ) { + viewModel.submitAction( + InternetPrivacyViewModel.Action.SelectLocationAction( + position + ) + ) + } + + override fun onNothingSelected(parentView: AdapterView<*>?) {} + } + } + + 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(getString(event.errorResId, *event.args.toTypedArray())) + } + } + } + } + } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } + } + } + + override fun getTitle(): String = getString(R.string.ipscrambling_title) + + private fun render(state: InternetPrivacyState) { + binding.radioUseHiddenIp.radiobutton.apply { + isChecked = state.mode in listOf( + InternetPrivacyMode.HIDE_IP, + InternetPrivacyMode.HIDE_IP_LOADING + ) + isEnabled = state.mode != InternetPrivacyMode.HIDE_IP_LOADING + } + binding.radioUseRealIp.radiobutton.apply { + isChecked = + state.mode in listOf( + InternetPrivacyMode.REAL_IP, + InternetPrivacyMode.REAL_IP_LOADING + ) + isEnabled = state.mode != InternetPrivacyMode.REAL_IP_LOADING + } + + binding.ipscramblingSelectLocation.setSelection(state.selectedLocationPosition) + + // TODO: this should not be mandatory. + binding.apps.post { + (binding.apps.adapter as ToggleAppsAdapter?)?.setData( + list = state.getApps(), + isEnabled = state.mode == InternetPrivacyMode.HIDE_IP + ) + } + + val viewIdsToHide = listOf( + binding.ipscramblingLocationLabel, + binding.selectLocationContainer, + binding.ipscramblingSelectLocation, + binding.ipscramblingSelectApps, + binding.apps + ) + + when { + state.mode in listOf( + InternetPrivacyMode.HIDE_IP_LOADING, + InternetPrivacyMode.REAL_IP_LOADING + ) + || state.availableApps.isEmpty() -> { + binding.loader.visibility = View.VISIBLE + viewIdsToHide.forEach { it.visibility = View.GONE } + } + else -> { + binding.loader.visibility = View.GONE + viewIdsToHide.forEach { it.visibility = View.VISIBLE } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyState.kt b/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyState.kt new file mode 100644 index 0000000..e0df73b --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyState.kt @@ -0,0 +1,36 @@ +/* + * 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.advancedprivacy.features.internetprivacy + +import foundation.e.advancedprivacy.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, +) { + fun getApps(): List<Pair<ApplicationDescription, Boolean>> { + return availableApps.map { it to (it.packageName !in bypassTorApps) } + } + + val selectedLocationPosition get() = availableLocationIds.indexOf(selectedLocation) +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyViewModel.kt b/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyViewModel.kt new file mode 100644 index 0000000..051c8e8 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyViewModel.kt @@ -0,0 +1,157 @@ +/* + * 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.advancedprivacy.features.internetprivacy + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode +import foundation.e.advancedprivacy.domain.usecases.AppListUseCase +import foundation.e.advancedprivacy.domain.usecases.GetQuickPrivacyStateUseCase +import foundation.e.advancedprivacy.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, + private val getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase, + 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() + + val availablesLocationsIds = listOf("", *ipScramblerModule.getAvailablesLocations().sorted().toTypedArray()) + + init { + viewModelScope.launch(Dispatchers.IO) { + _state.update { + it.copy( + mode = ipScramblingStateUseCase.internetPrivacyMode.value, + availableLocationIds = availablesLocationsIds, + selectedLocation = ipScramblerModule.exitCountry + ) + } + } + } + + @OptIn(FlowPreview::class) + suspend fun doOnStartedState() = withContext(Dispatchers.IO) { + launch { + merge( + appListUseCase.getAppsUsingInternet().map { apps -> + _state.update { s -> + s.copy( + availableApps = apps, + bypassTorApps = ipScramblingStateUseCase.bypassTorApps + ) + } + }, + ipScramblingStateUseCase.internetPrivacyMode.map { + _state.update { s -> s.copy(mode = it) } + } + ).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) + ) + } + } + + launch { + getQuickPrivacyStateUseCase.otherVpnRunning.collect { + _singleEvents.emit( + SingleEvent.ErrorEvent( + R.string.ipscrambling_error_always_on_vpn_already_running, + listOf(it.label ?: "") + ) + ) + _state.update { it.copy(forceRedraw = !it.forceRedraw) } + } + } + } + + 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) + } + } + + private fun actionUseRealIP() { + ipScramblingStateUseCase.toggle(hideIp = false) + } + + private fun actionUseHiddenIP() { + ipScramblingStateUseCase.toggle(hideIp = true) + } + + private suspend fun actionToggleAppIpScrambled(action: Action.ToggleAppIpScrambled) = withContext(Dispatchers.IO) { + ipScramblingStateUseCase.toggleBypassTor(action.packageName) + _state.update { it.copy(bypassTorApps = ipScramblingStateUseCase.bypassTorApps) } + } + + private suspend 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( + @StringRes val errorResId: Int, + val args: List<Any> = emptyList() + ) : SingleEvent() + } + + sealed class Action { + object UseRealIPAction : Action() + object UseHiddenIPAction : Action() + data class ToggleAppIpScrambled(val packageName: String) : Action() + data class SelectLocationAction(val position: Int) : Action() + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationFragment.kt b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationFragment.kt new file mode 100644 index 0000000..9934713 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationFragment.kt @@ -0,0 +1,376 @@ +/* + * 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.advancedprivacy.features.location + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.location.Location +import android.os.Bundle +import android.text.Editable +import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +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.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.google.android.material.textfield.TextInputLayout.END_ICON_CUSTOM +import com.google.android.material.textfield.TextInputLayout.END_ICON_NONE +import com.mapbox.mapboxsdk.Mapbox +import com.mapbox.mapboxsdk.WellKnownTileServer +import com.mapbox.mapboxsdk.camera.CameraPosition +import com.mapbox.mapboxsdk.camera.CameraUpdateFactory +import com.mapbox.mapboxsdk.geometry.LatLng +import com.mapbox.mapboxsdk.location.LocationComponent +import com.mapbox.mapboxsdk.location.LocationComponentActivationOptions +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.advancedprivacy.AdvancedPrivacyApplication +import foundation.e.advancedprivacy.DependencyContainer +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.common.NavToolbarFragment +import foundation.e.advancedprivacy.databinding.FragmentFakeLocationBinding +import foundation.e.advancedprivacy.domain.entities.LocationMode +import foundation.e.advancedprivacy.features.location.FakeLocationViewModel.Action +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch + +class FakeLocationFragment : NavToolbarFragment(R.layout.fragment_fake_location) { + + private var isFirstLaunch: Boolean = true + + private val dependencyContainer: DependencyContainer by lazy { + (this.requireActivity().application as AdvancedPrivacyApplication).dependencyContainer + } + + private val viewModel: FakeLocationViewModel by viewModels { + dependencyContainer.viewModelsFactory + } + + private var _binding: FragmentFakeLocationBinding? = null + private val binding get() = _binding!! + + private var mapboxMap: MapboxMap? = null + private var locationComponent: LocationComponent? = null + + private var inputJob: Job? = null + + private val locationPermissionRequest = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + if (permissions.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false) || + permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false) + ) { + viewModel.submitAction(Action.StartListeningLocation) + } // TODO: else. + } + + companion object { + private const val DEBOUNCE_PERIOD = 1000L + private const val MAP_STYLE = "mapbox://styles/mapbox/outdoors-v12" + } + + override fun onAttach(context: Context) { + super.onAttach(context) + Mapbox.getInstance(requireContext(), getString(R.string.mapbox_key), WellKnownTileServer.Mapbox) + } + + override fun getTitle(): String = getString(R.string.location_title) + + private fun displayToast(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT) + .show() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentFakeLocationBinding.bind(view) + + binding.mapView.setup(savedInstanceState) { mapboxMap -> + this.mapboxMap = mapboxMap + mapboxMap.uiSettings.isRotateGesturesEnabled = false + mapboxMap.setStyle(MAP_STYLE) { style -> + enableLocationPlugin(style) + mapboxMap.addOnCameraMoveListener { + if (binding.mapView.isEnabled) { + mapboxMap.cameraPosition.target?.let { + viewModel.submitAction( + Action.SetSpecificLocationAction( + it.latitude.toFloat(), + it.longitude.toFloat() + ) + ) + } + } + } + + mapboxMap.cameraPosition = CameraPosition.Builder().zoom(8.0).build() + + // Bind click listeners once map is ready. + bindClickListeners() + + render(viewModel.state.value) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect { event -> + if (event is FakeLocationViewModel.SingleEvent.LocationUpdatedEvent) { + updateLocation(event.location, event.mode) + } + } + } + } + } + } + + 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.RequestLocationPermission -> { + // TODO for standalone: rationale dialog + locationPermissionRequest.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + } + is FakeLocationViewModel.SingleEvent.LocationUpdatedEvent -> { + // Nothing here, another collect linked to mapbox view. + } + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } + } + } + + private fun getCoordinatesAfterTextChanged( + inputLayout: TextInputLayout, + editText: TextInputEditText, + isLat: Boolean + ) = { editable: Editable? -> + inputJob?.cancel() + if (editable != null && editable.isNotEmpty() && editText.isEnabled) { + inputJob = lifecycleScope.launch { + delay(DEBOUNCE_PERIOD) + ensureActive() + try { + val value = editable.toString().toFloat() + val maxValue = if (isLat) 90f else 180f + + if (value > maxValue || value < -maxValue) { + throw NumberFormatException("value $value is out of bounds") + } + inputLayout.error = null + + inputLayout.setEndIconDrawable(R.drawable.ic_valid) + inputLayout.endIconMode = END_ICON_CUSTOM + + // Here, value is valid, try to send the values + try { + val lat = binding.edittextLatitude.text.toString().toFloat() + val lon = binding.edittextLongitude.text.toString().toFloat() + if (lat <= 90f && lat >= -90f && lon <= 180f && lon >= -180f) { + mapboxMap?.moveCamera( + CameraUpdateFactory.newLatLng( + LatLng(lat.toDouble(), lon.toDouble()) + ) + ) + } + } catch (e: NumberFormatException) { + } + } catch (e: NumberFormatException) { + inputLayout.endIconMode = END_ICON_NONE + inputLayout.error = getString(R.string.location_input_error) + } + } + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun bindClickListeners() { + binding.radioUseRealLocation.setOnClickListener { + viewModel.submitAction(Action.UseRealLocationAction) + } + binding.radioUseRandomLocation.setOnClickListener { + viewModel.submitAction(Action.UseRandomLocationAction) + } + binding.radioUseSpecificLocation.setOnClickListener { + mapboxMap?.cameraPosition?.target?.let { + viewModel.submitAction( + Action.SetSpecificLocationAction(it.latitude.toFloat(), it.longitude.toFloat()) + ) + } + } + binding.edittextLatitude.addTextChangedListener( + afterTextChanged = getCoordinatesAfterTextChanged( + binding.textlayoutLatitude, + binding.edittextLatitude, + true + ) + ) + + binding.edittextLongitude.addTextChangedListener( + afterTextChanged = getCoordinatesAfterTextChanged( + binding.textlayoutLongitude, + binding.edittextLongitude, + false + ) + ) + } + + @SuppressLint("MissingPermission") + private fun render(state: FakeLocationState) { + binding.radioUseRandomLocation.isChecked = state.mode == LocationMode.RANDOM_LOCATION + + binding.radioUseSpecificLocation.isChecked = state.mode == LocationMode.SPECIFIC_LOCATION + + binding.radioUseRealLocation.isChecked = state.mode == LocationMode.REAL_LOCATION + + binding.mapView.isEnabled = (state.mode == LocationMode.SPECIFIC_LOCATION) + + if (state.mode == LocationMode.REAL_LOCATION) { + binding.centeredMarker.isVisible = false + } else { + binding.mapLoader.isVisible = false + binding.mapOverlay.isVisible = state.mode != LocationMode.SPECIFIC_LOCATION + binding.centeredMarker.isVisible = true + + mapboxMap?.moveCamera( + CameraUpdateFactory.newLatLng( + LatLng( + state.specificLatitude?.toDouble() ?: 0.0, + state.specificLongitude?.toDouble() ?: 0.0 + ) + ) + ) + } + + binding.textlayoutLatitude.isVisible = (state.mode == LocationMode.SPECIFIC_LOCATION) + binding.textlayoutLongitude.isVisible = (state.mode == LocationMode.SPECIFIC_LOCATION) + + binding.edittextLatitude.setText(state.specificLatitude?.toString()) + binding.edittextLongitude.setText(state.specificLongitude?.toString()) + } + + @SuppressLint("MissingPermission") + private fun updateLocation(lastLocation: Location?, mode: LocationMode) { + lastLocation?.let { location -> + locationComponent?.isLocationComponentEnabled = true + locationComponent?.forceLocationUpdate(location) + + if (mode == LocationMode.REAL_LOCATION) { + binding.mapLoader.isVisible = false + binding.mapOverlay.isVisible = false + + val update = CameraUpdateFactory.newLatLng( + LatLng(location.latitude, location.longitude) + ) + + if (isFirstLaunch) { + mapboxMap?.moveCamera(update) + isFirstLaunch = false + } else { + mapboxMap?.animateCamera(update) + } + } + } ?: run { + locationComponent?.isLocationComponentEnabled = false + if (mode == LocationMode.REAL_LOCATION) { + binding.mapLoader.isVisible = true + binding.mapOverlay.isVisible = true + } + } + } + + @SuppressLint("MissingPermission") + private fun enableLocationPlugin(@NonNull loadedMapStyle: Style) { + // Check if permissions are enabled and if not request + locationComponent = mapboxMap?.locationComponent + locationComponent?.activateLocationComponent( + LocationComponentActivationOptions.builder( + requireContext(), loadedMapStyle + ).useDefaultLocationEngine(false).build() + ) + locationComponent?.isLocationComponentEnabled = true + locationComponent?.cameraMode = CameraMode.NONE + locationComponent?.renderMode = RenderMode.NORMAL + } + + override fun onStart() { + super.onStart() + binding.mapView.onStart() + } + + override fun onResume() { + super.onResume() + viewModel.submitAction(Action.StartListeningLocation) + binding.mapView.onResume() + } + + override fun onPause() { + super.onPause() + viewModel.submitAction(Action.StopListeningLocation) + binding.mapView.onPause() + } + + override fun onStop() { + super.onStop() + binding.mapView.onStop() + } + + override fun onLowMemory() { + super.onLowMemory() + binding.mapView.onLowMemory() + } + + override fun onDestroyView() { + super.onDestroyView() + binding.mapView.onDestroy() + mapboxMap = null + locationComponent = null + inputJob = null + _binding = null + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationMapView.kt b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationMapView.kt new file mode 100644 index 0000000..fbb5b6c --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationMapView.kt @@ -0,0 +1,53 @@ +/* + * 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.advancedprivacy.features.location + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.util.AttributeSet +import android.view.MotionEvent +import com.mapbox.mapboxsdk.maps.MapView +import com.mapbox.mapboxsdk.maps.OnMapReadyCallback + +class FakeLocationMapView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : MapView(context, attrs, defStyleAttr) { + + /** + * Overrides onTouchEvent because this MapView is part of a scroll view + * and we want this map view to consume all touch events originating on this view. + */ + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean { + when (event?.action) { + MotionEvent.ACTION_DOWN -> parent.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_UP -> parent.requestDisallowInterceptTouchEvent(false) + } + super.onTouchEvent(event) + return true + } +} + +fun FakeLocationMapView.setup(savedInstanceState: Bundle?, callback: OnMapReadyCallback) = + this.apply { + onCreate(savedInstanceState) + getMapAsync(callback) + } diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationState.kt b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationState.kt new file mode 100644 index 0000000..baa672b --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationState.kt @@ -0,0 +1,29 @@ +/* + * 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.advancedprivacy.features.location + +import android.location.Location +import foundation.e.advancedprivacy.domain.entities.LocationMode + +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, +) diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationViewModel.kt b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationViewModel.kt new file mode 100644 index 0000000..87b64c5 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationViewModel.kt @@ -0,0 +1,126 @@ +/* + * 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.advancedprivacy.features.location + +import android.location.Location +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import foundation.e.advancedprivacy.domain.entities.LocationMode +import foundation.e.advancedprivacy.domain.usecases.FakeLocationStateUseCase +import foundation.e.advancedprivacy.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 _singleEvents = MutableSharedFlow<SingleEvent>() + val singleEvents = _singleEvents.asSharedFlow() + + 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 + ) + } + }, + 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: Action) = viewModelScope.launch { + when (action) { + is Action.StartListeningLocation -> actionStartListeningLocation() + is Action.StopListeningLocation -> fakeLocationStateUseCase.stopListeningLocation() + is Action.SetSpecificLocationAction -> setSpecificLocation(action) + is Action.UseRandomLocationAction -> fakeLocationStateUseCase.setRandomLocation() + is Action.UseRealLocationAction -> + fakeLocationStateUseCase.stopFakeLocation() + } + } + + private suspend fun actionStartListeningLocation() { + val started = fakeLocationStateUseCase.startListeningLocation() + if (!started) { + _singleEvents.emit(SingleEvent.RequestLocationPermission) + } + } + + private suspend fun setSpecificLocation(action: Action.SetSpecificLocationAction) { + specificLocationInputFlow.emit(action) + } + + sealed class SingleEvent { + data class LocationUpdatedEvent(val mode: LocationMode, val location: Location?) : SingleEvent() + object RequestLocationPermission : SingleEvent() + data class ErrorEvent(val error: String) : SingleEvent() + } + + sealed class Action { + object StartListeningLocation : Action() + object StopListeningLocation : Action() + object UseRealLocationAction : Action() + object UseRandomLocationAction : Action() + data class SetSpecificLocationAction( + val latitude: Float, + val longitude: Float + ) : Action() + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersFragment.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersFragment.kt new file mode 100644 index 0000000..3e17334 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersFragment.kt @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2021 E FOUNDATION, 2022 MURENA SAS + * + * 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.advancedprivacy.features.trackers + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.text.Spannable +import android.text.SpannableString +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan +import android.text.style.UnderlineSpan +import android.view.View +import android.widget.Toast +import androidx.core.content.ContextCompat +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 foundation.e.advancedprivacy.AdvancedPrivacyApplication +import foundation.e.advancedprivacy.DependencyContainer +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.common.AppsAdapter +import foundation.e.advancedprivacy.common.GraphHolder +import foundation.e.advancedprivacy.common.NavToolbarFragment +import foundation.e.advancedprivacy.common.setToolTipForAsterisk +import foundation.e.advancedprivacy.databinding.FragmentTrackersBinding +import foundation.e.advancedprivacy.databinding.TrackersItemGraphBinding +import foundation.e.advancedprivacy.domain.entities.TrackersPeriodicStatistics +import foundation.e.advancedprivacy.features.trackers.apptrackers.AppTrackersFragment +import kotlinx.coroutines.launch + +class TrackersFragment : + NavToolbarFragment(R.layout.fragment_trackers) { + + private val dependencyContainer: DependencyContainer by lazy { + (this.requireActivity().application as AdvancedPrivacyApplication).dependencyContainer + } + + private val viewModel: TrackersViewModel by viewModels { dependencyContainer.viewModelsFactory } + + private var _binding: FragmentTrackersBinding? = null + private val binding get() = _binding!! + + private var dayGraphHolder: GraphHolder? = null + private var monthGraphHolder: GraphHolder? = null + private var yearGraphHolder: GraphHolder? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + _binding = FragmentTrackersBinding.bind(view) + + dayGraphHolder = GraphHolder(binding.graphDay.graph, requireContext(), false) + monthGraphHolder = GraphHolder(binding.graphMonth.graph, requireContext(), false) + yearGraphHolder = GraphHolder(binding.graphYear.graph, requireContext(), false) + + binding.apps.apply { + layoutManager = LinearLayoutManager(requireContext()) + setHasFixedSize(true) + adapter = AppsAdapter(R.layout.trackers_item_app) { appUid -> + viewModel.submitAction( + TrackersViewModel.Action.ClickAppAction(appUid) + ) + } + } + + val infoText = getString(R.string.trackers_info) + val moreText = getString(R.string.trackers_info_more) + + val spannable = SpannableString("$infoText $moreText") + val startIndex = infoText.length + 1 + val endIndex = spannable.length + spannable.setSpan( + ForegroundColorSpan(ContextCompat.getColor(requireContext(), R.color.accent)), + startIndex, + endIndex, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + spannable.setSpan(UnderlineSpan(), startIndex, endIndex, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + spannable.setSpan( + object : ClickableSpan() { + override fun onClick(p0: View) { + viewModel.submitAction(TrackersViewModel.Action.ClickLearnMore) + } + }, + startIndex, endIndex, Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + + with(binding.trackersInfo) { + linksClickable = true + isClickable = true + movementMethod = LinkMovementMethod.getInstance() + text = spannable + } + + setToolTipForAsterisk( + textView = binding.trackersAppsListTitle, + textId = R.string.trackers_applist_title, + tooltipTextId = R.string.trackers_applist_infos + ) + + 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") + } + } + is TrackersViewModel.SingleEvent.OpenUrl -> { + try { + startActivity(Intent(Intent.ACTION_VIEW, event.url)) + } catch (e: ActivityNotFoundException) { + Toast.makeText( + requireContext(), + R.string.error_no_activity_view_url, + Toast.LENGTH_SHORT + ).show() + } + } + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } + } + } + + private fun displayToast(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT) + .show() + } + + override fun getTitle() = getString(R.string.trackers_title) + + private fun render(state: TrackersState) { + state.dayStatistics?.let { renderGraph(it, dayGraphHolder!!, binding.graphDay) } + state.monthStatistics?.let { renderGraph(it, monthGraphHolder!!, binding.graphMonth) } + state.yearStatistics?.let { renderGraph(it, yearGraphHolder!!, binding.graphYear) } + + state.apps?.let { + binding.apps.post { + (binding.apps.adapter as AppsAdapter?)?.dataSet = it + } + } + } + + private fun renderGraph( + statistics: TrackersPeriodicStatistics, + graphHolder: GraphHolder, + graphBinding: TrackersItemGraphBinding + ) { + if (statistics.callsBlockedNLeaked.all { it.first == 0 && it.second == 0 }) { + graphBinding.graph.visibility = View.INVISIBLE + graphBinding.graphEmpty.isVisible = true + } else { + graphBinding.graph.isVisible = true + graphBinding.graphEmpty.isVisible = false + graphHolder.data = statistics.callsBlockedNLeaked + graphHolder.labels = statistics.periods + graphBinding.trackersCountLabel.text = + getString(R.string.trackers_count_label, statistics.trackersCount) + } + } + + override fun onDestroyView() { + super.onDestroyView() + dayGraphHolder = null + monthGraphHolder = null + yearGraphHolder = null + _binding = null + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersState.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersState.kt new file mode 100644 index 0000000..13719e4 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersState.kt @@ -0,0 +1,28 @@ +/* + * 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.advancedprivacy.features.trackers + +import foundation.e.advancedprivacy.domain.entities.AppWithCounts +import foundation.e.advancedprivacy.domain.entities.TrackersPeriodicStatistics + +data class TrackersState( + val dayStatistics: TrackersPeriodicStatistics? = null, + val monthStatistics: TrackersPeriodicStatistics? = null, + val yearStatistics: TrackersPeriodicStatistics? = null, + val apps: List<AppWithCounts>? = null, +) diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersViewModel.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersViewModel.kt new file mode 100644 index 0000000..bcb4df8 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersViewModel.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2021 E FOUNDATION, 2022 MURENA SAS + * + * 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.advancedprivacy.features.trackers + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import foundation.e.advancedprivacy.domain.entities.AppWithCounts +import foundation.e.advancedprivacy.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 trackersStatisticsUseCase: TrackersStatisticsUseCase +) : ViewModel() { + + companion object { + private const val URL_LEARN_MORE_ABOUT_TRACKERS = + "https://doc.e.foundation/support-topics/advanced_privacy#trackers-blocker" + } + + private val _state = MutableStateFlow(TrackersState()) + val state = _state.asStateFlow() + + private val _singleEvents = MutableSharedFlow<SingleEvent>() + val singleEvents = _singleEvents.asSharedFlow() + + suspend fun doOnStartedState() = withContext(Dispatchers.IO) { + merge( + 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: Action) = viewModelScope.launch { + when (action) { + is Action.ClickAppAction -> actionClickApp(action) + is Action.ClickLearnMore -> + _singleEvents.emit(SingleEvent.OpenUrl(Uri.parse(URL_LEARN_MORE_ABOUT_TRACKERS))) + } + } + + private suspend fun actionClickApp(action: Action.ClickAppAction) { + state.value.apps?.find { it.uid == action.appUid }?.let { + _singleEvents.emit(SingleEvent.OpenAppDetailsEvent(it)) + } + } + + sealed class SingleEvent { + data class ErrorEvent(val error: String) : SingleEvent() + data class OpenAppDetailsEvent(val appDesc: AppWithCounts) : SingleEvent() + data class OpenUrl(val url: Uri) : SingleEvent() + } + + sealed class Action { + data class ClickAppAction(val appUid: Int) : Action() + object ClickLearnMore : Action() + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersFragment.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersFragment.kt new file mode 100644 index 0000000..2bb53d6 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersFragment.kt @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2023 MURENA SAS + * 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.advancedprivacy.features.trackers.apptrackers + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +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.advancedprivacy.AdvancedPrivacyApplication +import foundation.e.advancedprivacy.DependencyContainer +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.common.NavToolbarFragment +import foundation.e.advancedprivacy.databinding.ApptrackersFragmentBinding +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" + + 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_APP_UID to appUid + ) + } + + private val dependencyContainer: DependencyContainer by lazy { + (this.requireActivity().application as AdvancedPrivacyApplication).dependencyContainer + } + + private val viewModel: AppTrackersViewModel by viewModels { + dependencyContainer.viewModelsFactory + } + + private var _binding: ApptrackersFragmentBinding? = null + private val binding get() = _binding!! + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (arguments == null || + requireArguments().getInt(PARAM_APP_UID, Int.MIN_VALUE) == Int.MIN_VALUE + ) { + activity?.supportFragmentManager?.popBackStack() + } + } + + private fun displayToast(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT) + .show() + } + + override fun getTitle(): String = requireArguments().getString(PARAM_LABEL) ?: "" + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = ApptrackersFragmentBinding.bind(view) + + binding.blockAllToggle.setOnClickListener { + viewModel.submitAction(AppTrackersViewModel.Action.BlockAllToggleAction(binding.blockAllToggle.isChecked)) + } + binding.btnReset.setOnClickListener { + viewModel.submitAction(AppTrackersViewModel.Action.ResetAllTrackers) + } + + binding.trackers.apply { + layoutManager = LinearLayoutManager(requireContext()) + setHasFixedSize(true) + adapter = ToggleTrackersAdapter( + R.layout.apptrackers_item_tracker_toggle, + onToggleSwitch = { tracker, isBlocked -> + viewModel.submitAction(AppTrackersViewModel.Action.ToggleTrackerAction(tracker, isBlocked)) + }, + onClickTitle = { viewModel.submitAction(AppTrackersViewModel.Action.ClickTracker(it)) }, + ) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect { event -> + when (event) { + is AppTrackersViewModel.SingleEvent.ErrorEvent -> + displayToast(getString(event.errorResId)) + is AppTrackersViewModel.SingleEvent.OpenUrl -> + try { + startActivity(Intent(Intent.ACTION_VIEW, event.url)) + } catch (e: ActivityNotFoundException) { + Toast.makeText( + requireContext(), + R.string.error_no_activity_view_url, + Toast.LENGTH_SHORT + ).show() + } + is AppTrackersViewModel.SingleEvent.ToastTrackersControlDisabled -> + Snackbar.make( + binding.root, + R.string.apptrackers_tracker_control_disabled_message, + Snackbar.LENGTH_LONG + ).show() + } + } + } + } + + 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) + } + } + } + + private fun render(state: AppTrackersState) { + binding.trackersCountSummary.text = if (state.getTrackersCount() == 0) "" + else getString( + R.string.apptrackers_trackers_count_summary, + state.getBlockedTrackersCount(), + state.getTrackersCount(), + state.blocked, + state.leaked + ) + + binding.blockAllToggle.isChecked = state.isBlockingActivated + + val trackersStatus = state.getTrackersStatus() + if (!trackersStatus.isNullOrEmpty()) { + binding.trackersListTitle.isVisible = state.isBlockingActivated + binding.trackers.isVisible = true + binding.trackers.post { + (binding.trackers.adapter as ToggleTrackersAdapter?)?.updateDataSet( + trackersStatus, + state.isBlockingActivated + ) + } + binding.noTrackersYet.isVisible = false + binding.btnReset.isVisible = true + } else { + binding.trackersListTitle.isVisible = false + binding.trackers.isVisible = false + binding.noTrackersYet.isVisible = true + binding.noTrackersYet.text = getString( + when { + !state.isBlockingActivated -> R.string.apptrackers_no_trackers_yet_block_off + state.isWhitelistEmpty -> R.string.apptrackers_no_trackers_yet_block_on + else -> R.string.app_trackers_no_trackers_yet_remaining_whitelist + } + ) + binding.btnReset.isVisible = state.isBlockingActivated && !state.isWhitelistEmpty + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersState.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersState.kt new file mode 100644 index 0000000..2a9e6e8 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersState.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2023 MURENA SAS + * 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.advancedprivacy.features.trackers.apptrackers + +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import foundation.e.privacymodules.trackers.api.Tracker + +data class AppTrackersState( + val appDesc: ApplicationDescription? = null, + val isBlockingActivated: Boolean = false, + val trackersWithWhiteList: List<Pair<Tracker, Boolean>>? = null, + val leaked: Int = 0, + val blocked: Int = 0, + val isTrackersBlockingEnabled: Boolean = false, + val isWhitelistEmpty: Boolean = true, + val showQuickPrivacyDisabledMessage: Boolean = false, +) { + fun getTrackersStatus(): List<Pair<Tracker, Boolean>>? { + return trackersWithWhiteList?.map { it.first to !it.second } + } + + fun getTrackersCount() = trackersWithWhiteList?.size ?: 0 + fun getBlockedTrackersCount(): Int = if (isTrackersBlockingEnabled && isBlockingActivated) + trackersWithWhiteList?.count { !it.second } ?: 0 + else 0 +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersViewModel.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersViewModel.kt new file mode 100644 index 0000000..cda4b4b --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersViewModel.kt @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2023 MURENA SAS + * 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.advancedprivacy.features.trackers.apptrackers + +import android.net.Uri +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import foundation.e.advancedprivacy.domain.entities.TrackerMode +import foundation.e.advancedprivacy.domain.usecases.GetQuickPrivacyStateUseCase +import foundation.e.advancedprivacy.domain.usecases.TrackersStateUseCase +import foundation.e.advancedprivacy.domain.usecases.TrackersStatisticsUseCase +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import foundation.e.privacymodules.trackers.api.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 app: ApplicationDescription, + 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/trackers/" + } + + private val _state = MutableStateFlow(AppTrackersState()) + val state = _state.asStateFlow() + + private val _singleEvents = MutableSharedFlow<SingleEvent>() + val singleEvents = _singleEvents.asSharedFlow() + + init { + viewModelScope.launch(Dispatchers.IO) { + _state.update { + it.copy( + appDesc = app, + isBlockingActivated = !trackersStateUseCase.isWhitelisted(app), + trackersWithWhiteList = trackersStatisticsUseCase.getTrackersWithWhiteList( + app + ), + isWhitelistEmpty = trackersStatisticsUseCase.isWhiteListEmpty(app) + ) + } + } + } + + suspend fun doOnStartedState() = withContext(Dispatchers.IO) { + merge( + getQuickPrivacyStateUseCase.trackerMode.map { + _state.update { s -> s.copy(isTrackersBlockingEnabled = it != TrackerMode.VULNERABLE) } + }, + trackersStatisticsUseCase.listenUpdates().map { fetchStatistics() } + ).collect { } + } + + 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.ResetAllTrackers -> resetAllTrackers() + } + } + + private suspend fun blockAllToggleAction(action: Action.BlockAllToggleAction) { + withContext(Dispatchers.IO) { + if (!state.value.isTrackersBlockingEnabled) { + _singleEvents.emit(SingleEvent.ToastTrackersControlDisabled) + } + trackersStateUseCase.toggleAppWhitelist(app, !action.isBlocked) + _state.update { + it.copy( + isBlockingActivated = !trackersStateUseCase.isWhitelisted(app) + ) + } + } + } + + private suspend fun toggleTrackerAction(action: Action.ToggleTrackerAction) { + withContext(Dispatchers.IO) { + if (!state.value.isTrackersBlockingEnabled) { + _singleEvents.emit(SingleEvent.ToastTrackersControlDisabled) + } + + if (state.value.isBlockingActivated) { + trackersStateUseCase.blockTracker(app, action.tracker, action.isBlocked) + updateWhitelist() + } + } + } + + 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 suspend fun resetAllTrackers() { + withContext(Dispatchers.IO) { + trackersStateUseCase.clearWhitelist(app) + updateWhitelist() + } + } + private fun fetchStatistics() { + val (blocked, leaked) = trackersStatisticsUseCase.getCalls(app) + return _state.update { s -> + s.copy( + trackersWithWhiteList = trackersStatisticsUseCase.getTrackersWithWhiteList(app), + leaked = leaked, + blocked = blocked, + isWhitelistEmpty = trackersStatisticsUseCase.isWhiteListEmpty(app) + ) + } + } + + private fun updateWhitelist() { + _state.update { s -> + s.copy( + trackersWithWhiteList = trackersStatisticsUseCase.getTrackersWithWhiteList(app), + isWhitelistEmpty = trackersStatisticsUseCase.isWhiteListEmpty(app) + ) + } + } + + sealed class SingleEvent { + data class ErrorEvent(@StringRes val errorResId: Int) : SingleEvent() + data class OpenUrl(val url: Uri) : SingleEvent() + object ToastTrackersControlDisabled : 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 ResetAllTrackers : Action() + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/ToggleTrackersAdapter.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/ToggleTrackersAdapter.kt new file mode 100644 index 0000000..3696939 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/ToggleTrackersAdapter.kt @@ -0,0 +1,92 @@ +/* + * 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.advancedprivacy.features.trackers.apptrackers + +import android.text.SpannableString +import android.text.style.UnderlineSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Switch +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import foundation.e.advancedprivacy.R +import foundation.e.privacymodules.trackers.api.Tracker + +class ToggleTrackersAdapter( + private val itemsLayout: Int, + private val onToggleSwitch: (Tracker, Boolean) -> Unit, + private val onClickTitle: (Tracker) -> Unit +) : RecyclerView.Adapter<ToggleTrackersAdapter.ViewHolder>() { + + var isEnabled = true + + class ViewHolder( + view: View, + private val onToggleSwitch: (Tracker, Boolean) -> Unit, + private val onClickTitle: (Tracker) -> Unit + ) : RecyclerView.ViewHolder(view) { + val title: TextView = view.findViewById(R.id.title) + + val toggle: Switch = view.findViewById(R.id.toggle) + + fun bind(item: Pair<Tracker, Boolean>, isEnabled: Boolean) { + val text = item.first.label + if (item.first.exodusId != null) { + title.setTextColor(ContextCompat.getColor(title.context, R.color.accent)) + val spannable = SpannableString(text) + spannable.setSpan(UnderlineSpan(), 0, spannable.length, 0) + title.text = spannable + } else { + title.setTextColor(ContextCompat.getColor(title.context, R.color.primary_text)) + title.text = text + } + + toggle.isChecked = item.second + toggle.isEnabled = isEnabled + + toggle.setOnClickListener { + onToggleSwitch(item.first, toggle.isChecked) + } + + title.setOnClickListener { onClickTitle(item.first) } + } + } + + private var dataSet: List<Pair<Tracker, Boolean>> = emptyList() + + fun updateDataSet(new: List<Pair<Tracker, Boolean>>, isEnabled: Boolean) { + this.isEnabled = isEnabled + dataSet = new + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(itemsLayout, parent, false) + return ViewHolder(view, onToggleSwitch, onClickTitle) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val permission = dataSet[position] + holder.bind(permission, isEnabled) + } + + override fun getItemCount(): Int = dataSet.size +} |