aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java/foundation/e/advancedprivacy/features
diff options
context:
space:
mode:
authorGuillaume Jacquart <guillaume.jacquart@hoodbrains.com>2023-05-09 06:00:43 +0000
committerGuillaume Jacquart <guillaume.jacquart@hoodbrains.com>2023-05-09 06:00:43 +0000
commit5a432ecde520ee039786848296e5227571404158 (patch)
tree077eafb42d5d2d18b2ffc03bc93d9a8654377774 /app/src/main/java/foundation/e/advancedprivacy/features
parenta348c8196a643e4f5853587133b05e3ec2cd0faa (diff)
parenta8874167f663885f2d3371801cf03681576ac817 (diff)
downloadadvanced-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')
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardFragment.kt307
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardState.kt37
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardViewModel.kt158
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyFragment.kt201
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyState.kt36
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyViewModel.kt157
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationFragment.kt376
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationMapView.kt53
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationState.kt29
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationViewModel.kt126
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersFragment.kt218
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersState.kt28
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersViewModel.kt95
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersFragment.kt189
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersState.kt42
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersViewModel.kt172
-rw-r--r--app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/ToggleTrackersAdapter.kt92
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
+}