From 2e897cc8af4234abc4e3f5c3448e1fd7b2b8a1bd Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Tue, 5 Dec 2023 08:17:01 +0000 Subject: 1203 trackers oriented view --- app/build.gradle | 2 +- .../foundation/e/advancedprivacy/KoinModule.kt | 27 ++- .../e/advancedprivacy/common/AppsAdapter.kt | 71 ------- .../common/extensions/ViewPager2Extensions.kt | 43 ++++ .../domain/usecases/AppTrackersUseCase.kt | 83 ++++++++ .../domain/usecases/TrackerDetailsUseCase.kt | 55 +++++ .../domain/usecases/TrackersAndAppsListsUseCase.kt | 78 +++++++ .../domain/usecases/TrackersStateUseCase.kt | 20 +- .../domain/usecases/TrackersStatisticsUseCase.kt | 86 +------- .../features/trackers/AppsAdapter.kt | 62 ++++++ .../features/trackers/ListsTabPagerAdapter.kt | 123 +++++++++++ .../features/trackers/TrackerControlDisclaimer.kt | 81 ++++++++ .../features/trackers/TrackersAdapter.kt | 64 ++++++ .../features/trackers/TrackersFragment.kt | 172 ++++++++++------ .../features/trackers/TrackersState.kt | 17 +- .../features/trackers/TrackersViewModel.kt | 67 +++--- .../trackers/apptrackers/AppTrackersFragment.kt | 152 +++++++------- .../trackers/apptrackers/AppTrackersState.kt | 12 +- .../trackers/apptrackers/AppTrackersViewModel.kt | 102 ++++------ .../trackers/apptrackers/ToggleTrackersAdapter.kt | 72 ++++--- .../trackers/trackerdetails/TrackerAppsAdapter.kt | 67 ++++++ .../trackerdetails/TrackerDetailsFragment.kt | 149 ++++++++++++++ .../trackers/trackerdetails/TrackerDetailsState.kt | 31 +++ .../trackerdetails/TrackerDetailsViewModel.kt | 128 ++++++++++++ app/src/main/res/drawable/bg_stroke_rounded_12.xml | 21 ++ app/src/main/res/drawable/ic_shield_alert.xml | 15 ++ app/src/main/res/drawable/pill_shape_tab_bg.xml | 22 ++ .../main/res/drawable/pill_shape_tab_indicator.xml | 21 ++ .../main/res/drawable/pill_shape_tab_selected.xml | 22 ++ app/src/main/res/layout/apptrackers_fragment.xml | 208 ++++++++++++------- .../res/layout/apptrackers_item_tracker_toggle.xml | 22 +- .../main/res/layout/disclaimer_block_trackers.xml | 33 +++ app/src/main/res/layout/fragment_fake_location.xml | 19 +- app/src/main/res/layout/fragment_trackers.xml | 67 ++++-- app/src/main/res/layout/highlight_data_number.xml | 58 ++++++ .../main/res/layout/trackerdetails_fragment.xml | 135 +++++++++++++ app/src/main/res/layout/trackers_item_app.xml | 44 ++-- app/src/main/res/layout/trackers_list.xml | 24 +++ app/src/main/res/navigation/nav_graph.xml | 11 + app/src/main/res/values/colors.xml | 23 ++- app/src/main/res/values/strings.xml | 57 +++++- .../data/repositories/AppListsRepository.kt | 33 ++- gradle/libs.versions.toml | 1 + .../advancedprivacy/trackers/data/StatsDatabase.kt | 165 +++++++++++---- .../trackers/data/WhitelistRepository.kt | 225 +++++++++++++++++---- .../domain/usecases/FilterHostnameUseCase.kt | 10 +- .../trackers/domain/usecases/StatisticsUseCase.kt | 13 +- 47 files changed, 2338 insertions(+), 675 deletions(-) delete mode 100644 app/src/main/java/foundation/e/advancedprivacy/common/AppsAdapter.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/common/extensions/ViewPager2Extensions.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/domain/usecases/AppTrackersUseCase.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackerDetailsUseCase.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersAndAppsListsUseCase.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/features/trackers/AppsAdapter.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/features/trackers/ListsTabPagerAdapter.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackerControlDisclaimer.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersAdapter.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerAppsAdapter.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerDetailsFragment.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerDetailsState.kt create mode 100644 app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerDetailsViewModel.kt create mode 100644 app/src/main/res/drawable/bg_stroke_rounded_12.xml create mode 100644 app/src/main/res/drawable/ic_shield_alert.xml create mode 100644 app/src/main/res/drawable/pill_shape_tab_bg.xml create mode 100644 app/src/main/res/drawable/pill_shape_tab_indicator.xml create mode 100644 app/src/main/res/drawable/pill_shape_tab_selected.xml create mode 100644 app/src/main/res/layout/disclaimer_block_trackers.xml create mode 100644 app/src/main/res/layout/highlight_data_number.xml create mode 100644 app/src/main/res/layout/trackerdetails_fragment.xml create mode 100644 app/src/main/res/layout/trackers_list.xml diff --git a/app/build.gradle b/app/build.gradle index 95bbee6..a10ead1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -163,7 +163,7 @@ dependencies { libs.androidx.fragment.ktx, libs.androidx.lifecycle.runtime, libs.androidx.lifecycle.viewmodel, - + libs.androidx.viewpager2, libs.bundles.koin, libs.google.material, diff --git a/app/src/main/java/foundation/e/advancedprivacy/KoinModule.kt b/app/src/main/java/foundation/e/advancedprivacy/KoinModule.kt index efcd096..55183e9 100644 --- a/app/src/main/java/foundation/e/advancedprivacy/KoinModule.kt +++ b/app/src/main/java/foundation/e/advancedprivacy/KoinModule.kt @@ -27,10 +27,13 @@ import foundation.e.advancedprivacy.domain.entities.NotificationContent import foundation.e.advancedprivacy.domain.entities.ProfileType import foundation.e.advancedprivacy.domain.repositories.LocalStateRepository import foundation.e.advancedprivacy.domain.usecases.AppListUseCase +import foundation.e.advancedprivacy.domain.usecases.AppTrackersUseCase import foundation.e.advancedprivacy.domain.usecases.FakeLocationStateUseCase import foundation.e.advancedprivacy.domain.usecases.GetQuickPrivacyStateUseCase import foundation.e.advancedprivacy.domain.usecases.IpScramblingStateUseCase import foundation.e.advancedprivacy.domain.usecases.ShowFeaturesWarningUseCase +import foundation.e.advancedprivacy.domain.usecases.TrackerDetailsUseCase +import foundation.e.advancedprivacy.domain.usecases.TrackersAndAppsListsUseCase import foundation.e.advancedprivacy.domain.usecases.TrackersStateUseCase import foundation.e.advancedprivacy.domain.usecases.TrackersStatisticsUseCase import foundation.e.advancedprivacy.dummy.CityDataSource @@ -41,8 +44,11 @@ import foundation.e.advancedprivacy.features.internetprivacy.InternetPrivacyView import foundation.e.advancedprivacy.features.location.FakeLocationViewModel import foundation.e.advancedprivacy.features.trackers.TrackersViewModel import foundation.e.advancedprivacy.features.trackers.apptrackers.AppTrackersViewModel +import foundation.e.advancedprivacy.features.trackers.trackerdetails.TrackerDetailsViewModel import foundation.e.advancedprivacy.ipscrambler.ipScramblerModule import foundation.e.advancedprivacy.permissions.externalinterfaces.PermissionsPrivacyModuleImpl +import foundation.e.advancedprivacy.trackers.data.TrackersRepository +import foundation.e.advancedprivacy.trackers.domain.entities.Tracker import foundation.e.advancedprivacy.trackers.service.trackerServiceModule import foundation.e.advancedprivacy.trackers.trackersModule import org.koin.android.ext.koin.androidContext @@ -131,6 +137,10 @@ val appModule = module { singleOf(::ShowFeaturesWarningUseCase) singleOf(::TrackersStateUseCase) singleOf(::TrackersStatisticsUseCase) + singleOf(::TrackersAndAppsListsUseCase) + + singleOf(::AppTrackersUseCase) + singleOf(::TrackerDetailsUseCase) single { PermissionsPrivacyModuleImpl(context = androidContext()) @@ -144,9 +154,24 @@ val appModule = module { app = app, trackersStateUseCase = get(), trackersStatisticsUseCase = get(), - getQuickPrivacyStateUseCase = get() + getQuickPrivacyStateUseCase = get(), + appTrackersUseCase = get() ) } + + viewModel { parameters -> + val trackersRepository: TrackersRepository = get() + val tracker = trackersRepository.getTracker(parameters.get()) ?: Tracker("-1", emptySet(), "dummy", null) + + TrackerDetailsViewModel( + tracker = tracker, + trackersStateUseCase = get(), + trackersStatisticsUseCase = get(), + getQuickPrivacyStateUseCase = get(), + trackerDetailsUseCase = get() + ) + } + viewModelOf(::TrackersViewModel) viewModelOf(::FakeLocationViewModel) viewModelOf(::InternetPrivacyViewModel) diff --git a/app/src/main/java/foundation/e/advancedprivacy/common/AppsAdapter.kt b/app/src/main/java/foundation/e/advancedprivacy/common/AppsAdapter.kt deleted file mode 100644 index aee1890..0000000 --- a/app/src/main/java/foundation/e/advancedprivacy/common/AppsAdapter.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (C) 2021 E FOUNDATION, 2022 - 2023 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 . - */ - -package foundation.e.advancedprivacy.common - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import foundation.e.advancedprivacy.R -import foundation.e.advancedprivacy.domain.entities.AppWithCounts - -class AppsAdapter( - private val itemsLayout: Int, - private val listener: (Int) -> Unit -) : - RecyclerView.Adapter() { - - class ViewHolder(view: View, private val listener: (Int) -> Unit) : RecyclerView.ViewHolder(view) { - val appName: TextView = view.findViewById(R.id.title) - val counts: TextView = view.findViewById(R.id.counts) - val icon: ImageView = view.findViewById(R.id.icon) - fun bind(item: AppWithCounts) { - appName.text = item.label - counts.text = if (item.trackersCount > 0) itemView.context.getString( - R.string.trackers_app_trackers_counts, - item.blockedTrackersCount, - item.trackersCount, - item.leaks - ) else "" - icon.setImageDrawable(item.icon) - - itemView.setOnClickListener { listener(item.uid) } - } - } - - var dataSet: List = emptyList() - set(value) { - field = value - notifyDataSetChanged() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(itemsLayout, parent, false) - return ViewHolder(view, listener) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val app = dataSet[position] - holder.bind(app) - } - - override fun getItemCount(): Int = dataSet.size -} diff --git a/app/src/main/java/foundation/e/advancedprivacy/common/extensions/ViewPager2Extensions.kt b/app/src/main/java/foundation/e/advancedprivacy/common/extensions/ViewPager2Extensions.kt new file mode 100644 index 0000000..e17d692 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/common/extensions/ViewPager2Extensions.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2023 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 . + */ +package foundation.e.advancedprivacy.common.extensions + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 + +fun ViewPager2.findViewHolderForAdapterPosition(position: Int): RecyclerView.ViewHolder? { + return (getChildAt(0) as RecyclerView).findViewHolderForAdapterPosition(position) +} + +fun ViewPager2.updatePagerHeightForChild(itemView: View) { + itemView.post { + val wMeasureSpec = + View.MeasureSpec.makeMeasureSpec(itemView.width, View.MeasureSpec.EXACTLY) + val hMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + itemView.measure(wMeasureSpec, hMeasureSpec) + + if (layoutParams.height != itemView.measuredHeight) { + layoutParams = (layoutParams) + .also { lp -> + // applying Fragment Root View Height to + // the pager LayoutParams, so they match + lp.height = itemView.measuredHeight + } + } + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/AppTrackersUseCase.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/AppTrackersUseCase.kt new file mode 100644 index 0000000..92550ab --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/AppTrackersUseCase.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2023 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 . + */ +package foundation.e.advancedprivacy.domain.usecases + +import foundation.e.advancedprivacy.data.repositories.AppListsRepository +import foundation.e.advancedprivacy.domain.entities.ApplicationDescription +import foundation.e.advancedprivacy.trackers.data.StatsDatabase +import foundation.e.advancedprivacy.trackers.data.TrackersRepository +import foundation.e.advancedprivacy.trackers.data.WhitelistRepository +import foundation.e.advancedprivacy.trackers.domain.entities.Tracker +import foundation.e.advancedprivacy.trackers.domain.usecases.FilterHostnameUseCase + +class AppTrackersUseCase( + private val whitelistRepository: WhitelistRepository, + private val trackersStateUseCase: TrackersStateUseCase, + private val appListsRepository: AppListsRepository, + private val statsDatabase: StatsDatabase, + private val trackersRepository: TrackersRepository, + private val filterHostnameUseCase: FilterHostnameUseCase, +) { + suspend fun toggleAppWhitelist(app: ApplicationDescription, isBlocked: Boolean) { + appListsRepository.applyForHiddenApps(app) { + whitelistRepository.setWhiteListed(it.apId, !isBlocked) + val trackerIds = statsDatabase.getTrackerIds(listOf(app.apId)) + whitelistRepository.setWhitelistedTrackersForApp(it.apId, trackerIds, !isBlocked) + } + trackersStateUseCase.updateAllTrackersBlockedState() + } + + suspend fun clearWhitelist(app: ApplicationDescription) { + appListsRepository.applyForHiddenApps( + app + ) { + whitelistRepository.clearWhiteList(it.apId) + } + trackersStateUseCase.updateAllTrackersBlockedState() + } + + suspend fun getCalls(app: ApplicationDescription): Pair { + return appListsRepository.mapReduceForHiddenApps( + app = app, + map = { + statsDatabase.getCallsForApp(app.apId) + }, + reduce = { zip -> + zip.unzip().let { (blocked, leaked) -> + blocked.sum() to leaked.sum() + } + } + ) + } + + suspend fun getTrackersWithBlockedList(app: ApplicationDescription): List> { + val realApIds = appListsRepository.getRealApps(app).map { it.apId } + val trackers = statsDatabase.getTrackerIds(realApIds) + .mapNotNull { trackersRepository.getTracker(it) } + + return enrichWithBlockedState(app, trackers) + } + + suspend fun enrichWithBlockedState(app: ApplicationDescription, trackers: List): List> { + val realAppUids = appListsRepository.getRealApps(app).map { it.uid } + return trackers.map { tracker -> + tracker to !realAppUids.any { uid -> + filterHostnameUseCase.isWhitelisted(uid, tracker.id) + } + }.sortedBy { it.first.label.lowercase() } + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackerDetailsUseCase.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackerDetailsUseCase.kt new file mode 100644 index 0000000..27f3e78 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackerDetailsUseCase.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2023 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 . + */ +package foundation.e.advancedprivacy.domain.usecases + +import foundation.e.advancedprivacy.data.repositories.AppListsRepository +import foundation.e.advancedprivacy.domain.entities.ApplicationDescription +import foundation.e.advancedprivacy.trackers.data.StatsDatabase +import foundation.e.advancedprivacy.trackers.data.WhitelistRepository +import foundation.e.advancedprivacy.trackers.domain.entities.Tracker +import foundation.e.advancedprivacy.trackers.domain.usecases.FilterHostnameUseCase + +class TrackerDetailsUseCase( + private val whitelistRepository: WhitelistRepository, + private val trackersStateUseCase: TrackersStateUseCase, + private val appListsRepository: AppListsRepository, + private val statsDatabase: StatsDatabase, + private val filterHostnameUseCase: FilterHostnameUseCase, +) { + suspend fun toggleTrackerWhitelist(tracker: Tracker, isBlocked: Boolean) { + whitelistRepository.setWhiteListed(tracker, !isBlocked) + whitelistRepository.setWhitelistedAppsForTracker(statsDatabase.getApIds(tracker.id), tracker.id, !isBlocked) + trackersStateUseCase.updateAllTrackersBlockedState() + } + + suspend fun getAppsWithBlockedState(tracker: Tracker): List> { + return enrichWithBlockedState( + statsDatabase.getApIds(tracker.id).mapNotNull { + appListsRepository.getDisplayableApp(it) + }.sortedBy { it.label?.toString() }, + tracker + ) + } + + suspend fun enrichWithBlockedState(apps: List, tracker: Tracker): List> { + return apps.map { it to !filterHostnameUseCase.isWhitelisted(it.uid, tracker.id) } + } + + suspend fun getCalls(tracker: Tracker): Pair { + return statsDatabase.getCallsForTracker(tracker.id) + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersAndAppsListsUseCase.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersAndAppsListsUseCase.kt new file mode 100644 index 0000000..8292a6d --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersAndAppsListsUseCase.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2023 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 . + */ +package foundation.e.advancedprivacy.domain.usecases + +import foundation.e.advancedprivacy.data.repositories.AppListsRepository +import foundation.e.advancedprivacy.domain.entities.ApplicationDescription +import foundation.e.advancedprivacy.features.trackers.AppWithTrackersCount +import foundation.e.advancedprivacy.features.trackers.TrackerWithAppsCount +import foundation.e.advancedprivacy.trackers.data.StatsDatabase +import foundation.e.advancedprivacy.trackers.data.TrackersRepository +import foundation.e.advancedprivacy.trackers.domain.entities.Tracker +import kotlinx.coroutines.flow.first + +class TrackersAndAppsListsUseCase( + private val statsDatabase: StatsDatabase, + private val trackersRepository: TrackersRepository, + private val appListsRepository: AppListsRepository, +) { + + suspend fun getAppsAndTrackersCounts(): Pair, List> { + val trackersAndAppsIds = statsDatabase.getDistinctTrackerAndApp() + val trackersAndApps = mapIdsToEntities(trackersAndAppsIds) + val (countByApp, countByTracker) = foldToCountByEntityMaps(trackersAndApps) + + val appList = buildAppList(countByApp) + val trackerList = buildTrackerList(countByTracker) + return appList to trackerList + } + + private fun buildTrackerList(countByTracker: Map): List { + return countByTracker.map { (tracker, count) -> + TrackerWithAppsCount(tracker = tracker, appsCount = count) + }.sortedByDescending { it.appsCount } + } + + private suspend fun buildAppList(countByApp: Map): List { + return appListsRepository.apps().first().map { app: ApplicationDescription -> + AppWithTrackersCount(app = app, trackersCount = countByApp[app] ?: 0) + }.sortedByDescending { it.trackersCount } + } + + private suspend fun mapIdsToEntities(trackersAndAppsIds: List>): List> { + return trackersAndAppsIds.mapNotNull { (trackerId, apId) -> + trackersRepository.getTracker(trackerId)?.let { tracker -> + appListsRepository.getDisplayableApp(apId)?.let { app -> + tracker to app + } + } + // appListsRepository.getDisplayableApp() may transform many apId to one + // ApplicationDescription, so the lists is not distinct anymore. + }.distinct() + } + + private fun foldToCountByEntityMaps(trackersAndApps: List>): + Pair, Map> { + return trackersAndApps.fold( + mutableMapOf() to mutableMapOf() + ) { (countByApp, countByTracker), (tracker, app) -> + countByApp[app] = countByApp.getOrDefault(app, 0) + 1 + countByTracker[tracker] = countByTracker.getOrDefault(tracker, 0) + 1 + countByApp to countByTracker + } + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersStateUseCase.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersStateUseCase.kt index 2c47d70..dddc6a2 100644 --- a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersStateUseCase.kt +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersStateUseCase.kt @@ -41,7 +41,7 @@ class TrackersStateUseCase( } } - private fun updateAllTrackersBlockedState() { + fun updateAllTrackersBlockedState() { localStateRepository.areAllTrackersBlocked.value = whitelistRepository.isBlockingEnabled && whitelistRepository.areWhiteListEmpty() } @@ -50,28 +50,16 @@ class TrackersStateUseCase( return isWhitelisted(app, appListsRepository, whitelistRepository) } - fun toggleAppWhitelist(app: ApplicationDescription, isWhitelisted: Boolean) { - appListsRepository.applyForHiddenApps(app) { - whitelistRepository.setWhiteListed(it.apId, isWhitelisted) - } - updateAllTrackersBlockedState() + fun isWhitelisted(tracker: Tracker): Boolean { + return whitelistRepository.isWhiteListed(tracker) } - fun blockTracker(app: ApplicationDescription, tracker: Tracker, isBlocked: Boolean) { + suspend fun blockTracker(app: ApplicationDescription, tracker: Tracker, isBlocked: Boolean) { appListsRepository.applyForHiddenApps(app) { whitelistRepository.setWhiteListed(tracker, it.apId, !isBlocked) } updateAllTrackersBlockedState() } - - fun clearWhitelist(app: ApplicationDescription) { - appListsRepository.applyForHiddenApps( - app - ) { - whitelistRepository.clearWhiteList(it.apId) - } - updateAllTrackersBlockedState() - } } fun isWhitelisted( diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersStatisticsUseCase.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersStatisticsUseCase.kt index 3d6ade0..8f290b8 100644 --- a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersStatisticsUseCase.kt +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersStatisticsUseCase.kt @@ -1,5 +1,6 @@ /* - * Copyright (C) 2021 E FOUNDATION, 2022 - 2023 MURENA SAS + * Copyright (C) 2022 - 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 @@ -21,7 +22,6 @@ import android.content.res.Resources import foundation.e.advancedprivacy.R import foundation.e.advancedprivacy.common.throttleFirst import foundation.e.advancedprivacy.data.repositories.AppListsRepository -import foundation.e.advancedprivacy.domain.entities.AppWithCounts import foundation.e.advancedprivacy.domain.entities.ApplicationDescription import foundation.e.advancedprivacy.domain.entities.TrackersPeriodicStatistics import foundation.e.advancedprivacy.trackers.data.StatsDatabase @@ -167,27 +167,7 @@ class TrackersStatisticsUseCase( ) } - fun getTrackersWithWhiteList(app: ApplicationDescription): List> { - return appListsRepository.mapReduceForHiddenApps( - app = app, - map = { appDesc: ApplicationDescription -> - ( - statisticsUseCase.getTrackers(listOf(appDesc)) to - getWhiteList(appDesc) - ) - }, - reduce = { lists -> - lists.unzip().let { (trackerLists, whiteListedIdLists) -> - val whiteListedIds = whiteListedIdLists.flatten().map { it.id }.toSet() - - trackerLists.flatten().distinctBy { it.id }.sortedBy { it.label.lowercase() } - .map { tracker -> tracker to (tracker.id in whiteListedIds) } - } - } - ) - } - - fun isWhiteListEmpty(app: ApplicationDescription): Boolean { + suspend fun isWhiteListEmpty(app: ApplicationDescription): Boolean { return appListsRepository.mapReduceForHiddenApps( app = app, map = { appDesc: ApplicationDescription -> @@ -197,7 +177,7 @@ class TrackersStatisticsUseCase( ) } - fun getCalls(app: ApplicationDescription): Pair { + suspend fun getCalls(app: ApplicationDescription): Pair { return appListsRepository.mapReduceForHiddenApps( app = app, map = { @@ -211,67 +191,9 @@ class TrackersStatisticsUseCase( ) } - fun getAppsWithCounts(): Flow> { - val trackersCounts = statisticsUseCase.getContactedTrackersCountByApp() - val hiddenAppsTrackersWithWhiteList = - getTrackersWithWhiteList(appListsRepository.dummySystemApp) - val acAppsTrackersWithWhiteList = - getTrackersWithWhiteList(appListsRepository.dummyCompatibilityApp) - - return appListsRepository.apps() - .map { apps -> - val callsByApp = statisticsUseCase.getCallsByApps(24, ChronoUnit.HOURS) - apps.map { app -> - val calls = appListsRepository.mapReduceForHiddenApps( - app = app, - map = { callsByApp.getOrDefault(app, 0 to 0) }, - reduce = { - it.unzip().let { (blocked, leaked) -> - blocked.sum() to leaked.sum() - } - } - ) - - AppWithCounts( - app = app, - isWhitelisted = !whitelistRepository.isBlockingEnabled || - isWhitelisted(app, appListsRepository, whitelistRepository), - trackersCount = when (app) { - appListsRepository.dummySystemApp -> - hiddenAppsTrackersWithWhiteList.size - appListsRepository.dummyCompatibilityApp -> - acAppsTrackersWithWhiteList.size - else -> trackersCounts.getOrDefault(app, 0) - }, - whiteListedTrackersCount = when (app) { - appListsRepository.dummySystemApp -> - hiddenAppsTrackersWithWhiteList.count { it.second } - appListsRepository.dummyCompatibilityApp -> - acAppsTrackersWithWhiteList.count { it.second } - else -> - getWhiteList(app).size - }, - blockedLeaks = calls.first, - leaks = calls.second - ) - } - .sortedWith(mostLeakedAppsComparator) - } - } - private fun getWhiteList(app: ApplicationDescription): List { return whitelistRepository.getWhiteListForApp(app).mapNotNull { trackersRepository.getTracker(it) } } - - private val mostLeakedAppsComparator: Comparator = Comparator { o1, o2 -> - val leaks = o2.leaks - o1.leaks - if (leaks != 0) leaks else { - val whitelisted = o2.whiteListedTrackersCount - o1.whiteListedTrackersCount - if (whitelisted != 0) whitelisted else { - o2.trackersCount - o1.trackersCount - } - } - } } diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/AppsAdapter.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/AppsAdapter.kt new file mode 100644 index 0000000..f00dff8 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/AppsAdapter.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2021 E FOUNDATION, 2022 - 2023 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 . + */ + +package foundation.e.advancedprivacy.features.trackers + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.databinding.TrackersItemAppBinding + +class AppsAdapter( + private val viewModel: TrackersViewModel +) : + RecyclerView.Adapter() { + + class ViewHolder(view: View, private val parentViewModel: TrackersViewModel) : RecyclerView.ViewHolder(view) { + val binding = TrackersItemAppBinding.bind(view) + fun bind(item: AppWithTrackersCount) { + binding.icon.setImageDrawable(item.app.icon) + binding.title.text = item.app.label + binding.counts.text = itemView.context.getString(R.string.trackers_list_app_trackers_counts, item.trackersCount.toString()) + itemView.setOnClickListener { + parentViewModel.onClickApp(item.app) + } + } + } + + var dataSet: List = emptyList() + set(value) { + field = value + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.trackers_item_app, parent, false) + return ViewHolder(view, viewModel) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val app = dataSet[position] + holder.bind(app) + } + + override fun getItemCount(): Int = dataSet.size +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/ListsTabPagerAdapter.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/ListsTabPagerAdapter.kt new file mode 100644 index 0000000..2420410 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/ListsTabPagerAdapter.kt @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2023 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 . + */ +package foundation.e.advancedprivacy.features.trackers + +import android.content.Context +import android.content.res.Resources +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.divider.MaterialDividerItemDecoration +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.databinding.TrackersListBinding + +const val TAB_APPS = 0 +private const val TAB_TRACKERS = 1 + +class ListsTabPagerAdapter( + private val context: Context, + private val viewModel: TrackersViewModel, +) : RecyclerView.Adapter() { + private var apps: List = emptyList() + private var trackers: List = emptyList() + + fun updateDataSet(apps: List?, trackers: List?) { + this.apps = apps ?: emptyList() + this.trackers = trackers ?: emptyList() + notifyDataSetChanged() + } + + override fun getItemViewType(position: Int): Int = position + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListsTabViewHolder { + val binding = TrackersListBinding.inflate(LayoutInflater.from(context), parent, false) + return when (viewType) { + TAB_APPS -> { + ListsTabViewHolder.AppsListViewHolder(binding, viewModel) + } + else -> { + ListsTabViewHolder.TrackersListViewHolder(binding, viewModel) + } + } + } + + override fun getItemCount(): Int { + return 2 + } + + override fun onBindViewHolder(holder: ListsTabViewHolder, position: Int) { + when (position) { + TAB_APPS -> { + (holder as ListsTabViewHolder.AppsListViewHolder).onBind(apps) + } + TAB_TRACKERS -> { + (holder as ListsTabViewHolder.TrackersListViewHolder).onBind(trackers) + } + } + } + + sealed class ListsTabViewHolder(view: View) : RecyclerView.ViewHolder(view) { + protected fun setupRecyclerView(recyclerView: RecyclerView) { + recyclerView.apply { + layoutManager = LinearLayoutManager(context) + setHasFixedSize(true) + addItemDecoration( + MaterialDividerItemDecoration(context, LinearLayoutManager.VERTICAL).apply { + dividerColor = ContextCompat.getColor(context, R.color.divider) + dividerInsetStart = 16.dpToPx() + dividerInsetEnd = 16.dpToPx() + } + ) + } + } + + private fun Int.dpToPx(): Int { + return (this * Resources.getSystem().displayMetrics.density).toInt() + } + + class AppsListViewHolder( + private val binding: TrackersListBinding, + private val viewModel: TrackersViewModel + ) : ListsTabViewHolder(binding.root) { + init { + setupRecyclerView(binding.list) + binding.list.adapter = AppsAdapter(viewModel) + } + + fun onBind(apps: List) { + (binding.list.adapter as AppsAdapter).dataSet = apps + } + } + + class TrackersListViewHolder( + private val binding: TrackersListBinding, + private val viewModel: TrackersViewModel + ) : ListsTabViewHolder(binding.root) { + init { + setupRecyclerView(binding.list) + binding.list.adapter = TrackersAdapter(viewModel) + } + + fun onBind(trackers: List) { + (binding.list.adapter as TrackersAdapter).dataSet = trackers + } + } + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackerControlDisclaimer.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackerControlDisclaimer.kt new file mode 100644 index 0000000..183a5ca --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackerControlDisclaimer.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2023 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 . + */ +package foundation.e.advancedprivacy.features.trackers + +import android.content.Context +import android.text.Spannable +import android.text.SpannableString +import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan +import android.text.style.UnderlineSpan +import android.view.View +import android.widget.TextView +import androidx.core.content.ContextCompat +import foundation.e.advancedprivacy.R + +const val URL_LEARN_MORE_ABOUT_TRACKERS = "https://doc.e.foundation/support-topics/advanced_privacy#trackers-blocker" + +fun setupDisclaimerBlock(view: TextView, onClickLearnMore: () -> Unit) { + with(view) { + linksClickable = true + isClickable = true + movementMethod = android.text.method.LinkMovementMethod.getInstance() + text = buildSpan(view.context, onClickLearnMore) + } +} + +private fun buildSpan(context: Context, onClickLearnMore: () -> Unit): SpannableString { + val start = context.getString(R.string.trackercontroldisclaimer_start) + val body = context.getString(R.string.trackercontroldisclaimer_body) + val link = context.getString(R.string.trackercontroldisclaimer_link) + + val spannable = SpannableString("$start $body $link") + + val startEndIndex = start.length + 1 + val linkStartIndex = startEndIndex + body.length + 1 + val linkEndIndex = spannable.length + spannable.setSpan( + ForegroundColorSpan(ContextCompat.getColor(context, R.color.primary_text)), + 0, + startEndIndex, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + + spannable.setSpan( + ForegroundColorSpan(ContextCompat.getColor(context, R.color.disabled)), + startEndIndex, + linkStartIndex, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + + spannable.setSpan( + ForegroundColorSpan(ContextCompat.getColor(context, R.color.accent)), + linkStartIndex, + linkEndIndex, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + spannable.setSpan(UnderlineSpan(), linkStartIndex, linkEndIndex, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + spannable.setSpan( + object : ClickableSpan() { + override fun onClick(p0: View) { + onClickLearnMore.invoke() + } + }, + linkStartIndex, linkEndIndex, Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + return spannable +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersAdapter.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersAdapter.kt new file mode 100644 index 0000000..3270bf3 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersAdapter.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2023 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 . + */ + +package foundation.e.advancedprivacy.features.trackers + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.databinding.TrackersItemAppBinding + +class TrackersAdapter( + val viewModel: TrackersViewModel +) : + RecyclerView.Adapter() { + + class ViewHolder(view: View, private val parentViewModel: TrackersViewModel) : RecyclerView.ViewHolder(view) { + val binding = TrackersItemAppBinding.bind(view) + init { + binding.icon.isVisible = false + } + fun bind(item: TrackerWithAppsCount) { + binding.title.text = item.tracker.label + binding.counts.text = itemView.context.getString(R.string.trackers_list_tracker_apps_counts, item.appsCount.toString()) + itemView.setOnClickListener { + parentViewModel.onClickTracker(item.tracker) + } + } + } + + var dataSet: List = emptyList() + set(value) { + field = value + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.trackers_item_app, parent, false) + return ViewHolder(view, viewModel) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val app = dataSet[position] + holder.bind(app) + } + + override fun getItemCount(): Int = dataSet.size +} 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 index 132fa3b..b016c5e 100644 --- a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersFragment.kt +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersFragment.kt @@ -28,6 +28,7 @@ import android.text.style.ClickableSpan import android.text.style.ForegroundColorSpan import android.text.style.UnderlineSpan import android.view.View +import android.view.ViewTreeObserver import android.widget.Toast import androidx.core.content.ContextCompat import androidx.core.view.isVisible @@ -35,12 +36,13 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.tabs.TabLayoutMediator 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.common.extensions.findViewHolderForAdapterPosition +import foundation.e.advancedprivacy.common.extensions.updatePagerHeightForChild import foundation.e.advancedprivacy.databinding.FragmentTrackersBinding import foundation.e.advancedprivacy.databinding.TrackersItemGraphBinding import foundation.e.advancedprivacy.domain.entities.TrackersPeriodicStatistics @@ -50,32 +52,98 @@ import org.koin.androidx.viewmodel.ext.android.viewModel class TrackersFragment : NavToolbarFragment(R.layout.fragment_trackers) { private val viewModel: TrackersViewModel by viewModel() - private var _binding: FragmentTrackersBinding? = null - private val binding get() = _binding!! + private lateinit var binding: FragmentTrackersBinding private var dayGraphHolder: GraphHolder? = null private var monthGraphHolder: GraphHolder? = null private var yearGraphHolder: GraphHolder? = null + private lateinit var tabAdapter: ListsTabPagerAdapter + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - _binding = FragmentTrackersBinding.bind(view) + 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) - ) + tabAdapter = ListsTabPagerAdapter(requireContext(), viewModel) + binding.listsPager.adapter = tabAdapter + + TabLayoutMediator(binding.listsTabs, binding.listsPager) { tab, position -> + tab.text = getString( + when (position) { + TAB_APPS -> R.string.trackers_toggle_list_apps + else -> R.string.trackers_toggle_list_trackers + } + ) + }.attach() + + binding.listsPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + if (state == ViewPager2.SCROLL_STATE_IDLE) { + updatePagerHeight() + } + } + }) + + setupTrackersInfos() + + listenViewModel() + } + + private fun listenViewModel() { + with(viewLifecycleOwner) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + render(viewModel.state.value) + viewModel.state.collect(::render) + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect(::handleEvents) + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.navigate.collect(findNavController()::navigate) + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } + } + } + } + + private fun handleEvents(event: TrackersViewModel.SingleEvent) { + when (event) { + is TrackersViewModel.SingleEvent.ErrorEvent -> { + displayToast(event.error) + } + 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() + } } } + } + private fun setupTrackersInfos() { val infoText = getString(R.string.trackers_info) val moreText = getString(R.string.trackers_info_more) @@ -92,7 +160,7 @@ class TrackersFragment : NavToolbarFragment(R.layout.fragment_trackers) { spannable.setSpan( object : ClickableSpan() { override fun onClick(p0: View) { - viewModel.submitAction(TrackersViewModel.Action.ClickLearnMore) + viewModel.onClickLearnMore() } }, startIndex, endIndex, Spannable.SPAN_INCLUSIVE_EXCLUSIVE @@ -104,71 +172,44 @@ class TrackersFragment : NavToolbarFragment(R.layout.fragment_trackers) { 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) + private var oldPosition = -1 + private val layoutListener = ViewTreeObserver.OnGlobalLayoutListener { + binding.listsPager.findViewHolderForAdapterPosition(binding.listsPager.currentItem) + .let { currentViewHolder -> + currentViewHolder?.itemView?.let { binding.listsPager.updatePagerHeightForChild(it) } } - } + } - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.singleEvents.collect { event -> - when (event) { - is TrackersViewModel.SingleEvent.ErrorEvent -> { - displayToast(event.error) - } - 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() - } - } - } - } + private fun updatePagerHeight() { + with(binding.listsPager) { + val position = currentItem + if (position == oldPosition) return + if (oldPosition > 0) { + val oldItem = findViewHolderForAdapterPosition(oldPosition)?.itemView + oldItem?.viewTreeObserver?.removeOnGlobalLayoutListener(layoutListener) } - } - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.navigate.collect(findNavController()::navigate) - } - } + val newItem = findViewHolderForAdapterPosition(position)?.itemView + newItem?.viewTreeObserver?.addOnGlobalLayoutListener(layoutListener) - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.doOnStartedState() - } + oldPosition = position + adapter?.notifyItemChanged(position) } } private fun displayToast(message: String) { - Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT) - .show() + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() } 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) } + updatePagerHeight() - state.apps?.let { - binding.apps.post { - (binding.apps.adapter as AppsAdapter?)?.dataSet = it - } - } + tabAdapter.updateDataSet(state.apps, state.trackers) } private fun renderGraph( @@ -191,9 +232,14 @@ class TrackersFragment : NavToolbarFragment(R.layout.fragment_trackers) { override fun onDestroyView() { super.onDestroyView() + kotlin.runCatching { + if (oldPosition >= 0) { + val oldItem = binding.listsPager.findViewHolderForAdapterPosition(oldPosition) + oldItem?.itemView?.viewTreeObserver?.removeOnGlobalLayoutListener(layoutListener) + } + } 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 index 13719e4..7f5fdfe 100644 --- a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersState.kt +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersState.kt @@ -1,4 +1,5 @@ /* + * Copyright (C) 2023 MURENA SAS * Copyright (C) 2022 E FOUNDATION * * This program is free software: you can redistribute it and/or modify @@ -17,12 +18,24 @@ package foundation.e.advancedprivacy.features.trackers -import foundation.e.advancedprivacy.domain.entities.AppWithCounts +import foundation.e.advancedprivacy.domain.entities.ApplicationDescription import foundation.e.advancedprivacy.domain.entities.TrackersPeriodicStatistics +import foundation.e.advancedprivacy.trackers.domain.entities.Tracker data class TrackersState( val dayStatistics: TrackersPeriodicStatistics? = null, val monthStatistics: TrackersPeriodicStatistics? = null, val yearStatistics: TrackersPeriodicStatistics? = null, - val apps: List? = null, + val apps: List? = null, + val trackers: List? = null +) + +data class AppWithTrackersCount( + val app: ApplicationDescription, + val trackersCount: Int = 0 +) + +data class TrackerWithAppsCount( + val tracker: Tracker, + val appsCount: Int = 0 ) 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 index 8a5d0f0..31da8ca 100644 --- a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersViewModel.kt +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersViewModel.kt @@ -22,27 +22,24 @@ import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavDirections +import foundation.e.advancedprivacy.domain.entities.ApplicationDescription +import foundation.e.advancedprivacy.domain.usecases.TrackersAndAppsListsUseCase import foundation.e.advancedprivacy.domain.usecases.TrackersStatisticsUseCase +import foundation.e.advancedprivacy.trackers.domain.entities.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 TrackersViewModel( - private val trackersStatisticsUseCase: TrackersStatisticsUseCase + private val trackersStatisticsUseCase: TrackersStatisticsUseCase, + private val trackersAndAppsListsUseCase: TrackersAndAppsListsUseCase ) : 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() @@ -53,46 +50,40 @@ class TrackersViewModel( val navigate = _navigate.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.listenUpdates().collect { + 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) } + } + + trackersAndAppsListsUseCase.getAppsAndTrackersCounts().let { (appList, trackerList) -> + _state.update { + it.copy(apps = appList, trackers = trackerList) + } } - ).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))) - } + fun onClickTracker(tracker: Tracker) = viewModelScope.launch { + _navigate.emit(TrackersFragmentDirections.gotoTrackerDetailsFragment(trackerId = tracker.id)) } - private suspend fun actionClickApp(action: Action.ClickAppAction) { - state.value.apps?.find { it.uid == action.appUid }?.let { - _navigate.emit(TrackersFragmentDirections.gotoAppTrackersFragment(appUid = it.uid)) - } + fun onClickApp(app: ApplicationDescription) = viewModelScope.launch { + _navigate.emit(TrackersFragmentDirections.gotoAppTrackersFragment(appUid = app.uid)) + } + + fun onClickLearnMore() = viewModelScope.launch { + _singleEvents.emit(SingleEvent.OpenUrl(Uri.parse(URL_LEARN_MORE_ABOUT_TRACKERS))) } sealed class SingleEvent { data class ErrorEvent(val error: String) : 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 index 7fb9ca6..85c5350 100644 --- 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 @@ -23,16 +23,19 @@ import android.content.Intent import android.os.Bundle import android.view.View import android.widget.Toast +import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.divider.MaterialDividerItemDecoration import com.google.android.material.snackbar.Snackbar import foundation.e.advancedprivacy.R import foundation.e.advancedprivacy.common.NavToolbarFragment import foundation.e.advancedprivacy.databinding.ApptrackersFragmentBinding +import foundation.e.advancedprivacy.features.trackers.setupDisclaimerBlock import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf @@ -42,8 +45,7 @@ class AppTrackersFragment : NavToolbarFragment(R.layout.apptrackers_fragment) { private val args: AppTrackersFragmentArgs by navArgs() private val viewModel: AppTrackersViewModel by viewModel { parametersOf(args.appUid) } - private var _binding: ApptrackersFragmentBinding? = null - private val binding get() = _binding!! + private lateinit var binding: ApptrackersFragmentBinding override fun getTitle(): CharSequence { return "" @@ -56,96 +58,111 @@ class AppTrackersFragment : NavToolbarFragment(R.layout.apptrackers_fragment) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - _binding = ApptrackersFragmentBinding.bind(view) + binding = ApptrackersFragmentBinding.bind(view) binding.blockAllToggle.setOnClickListener { - viewModel.submitAction(AppTrackersViewModel.Action.BlockAllToggleAction(binding.blockAllToggle.isChecked)) - } - binding.btnReset.setOnClickListener { - viewModel.submitAction(AppTrackersViewModel.Action.ResetAllTrackers) + viewModel.onToggleBlockAll(binding.blockAllToggle.isChecked) } + binding.btnReset.setOnClickListener { viewModel.onClickResetAllTrackers() } - binding.trackers.apply { + binding.list.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)) }, + addItemDecoration( + MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL).apply { + dividerColor = ContextCompat.getColor(requireContext(), R.color.divider) + } ) + adapter = ToggleTrackersAdapter(viewModel) } - 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() - } + listenViewModel() + + setupDisclaimerBlock(binding.disclaimerBlockTrackers.root, viewModel::onClickLearnMore) + } + + private fun listenViewModel() { + with(viewLifecycleOwner) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect(::handleEvents) } } - } - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.doOnStartedState() + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } } - } - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - render(viewModel.state.value) - viewModel.state.collect(::render) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + render(viewModel.state.value) + viewModel.state.collect(::render) + } } } } + private fun handleEvents(event: AppTrackersViewModel.SingleEvent) { + 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() + } + } private fun render(state: AppTrackersState) { setTitle(state.appDesc?.label) - binding.trackersCountSummary.text = if (state.getTrackersCount() == 0) "" - else getString( - R.string.apptrackers_trackers_count_summary, - state.getBlockedTrackersCount(), - state.getTrackersCount(), - state.blocked, - state.leaked - ) + binding.subtitle.text = getString(R.string.apptrackers_subtitle, state.appDesc?.label) + binding.dataDetectedTrackers.apply { + primaryMessage.setText(R.string.apptrackers_detected_tracker_primary) + number.text = state.getTrackersCount().toString() + secondaryMessage.setText(R.string.apptrackers_detected_tracker_secondary) + } + + binding.dataBlockedTrackers.apply { + primaryMessage.setText(R.string.apptrackers_blocked_tracker_primary) + number.text = state.getBlockedTrackersCount().toString() + secondaryMessage.setText(R.string.apptrackers_blocked_tracker_secondary) + } + + binding.dataBlockedLeaks.apply { + primaryMessage.setText(R.string.apptrackers_blocked_leaks_primary) + number.text = state.blocked.toString() + secondaryMessage.text = getString(R.string.apptrackers_blocked_leaks_secondary, state.leaked.toString()) + } 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 - ) + val trackersStatus = state.trackersWithBlockedList + if (!trackersStatus.isEmpty()) { + binding.listTitle.isVisible = true + binding.list.isVisible = true + binding.list.post { + (binding.list.adapter as ToggleTrackersAdapter?)?.updateDataSet(trackersStatus) } binding.noTrackersYet.isVisible = false binding.btnReset.isVisible = true } else { - binding.trackersListTitle.isVisible = false - binding.trackers.isVisible = false + binding.listTitle.isVisible = false + binding.list.isVisible = false binding.noTrackersYet.isVisible = true binding.noTrackersYet.text = getString( when { @@ -157,9 +174,4 @@ class AppTrackersFragment : NavToolbarFragment(R.layout.apptrackers_fragment) { 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 index a597da6..cea99a6 100644 --- 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 @@ -24,19 +24,13 @@ import foundation.e.advancedprivacy.trackers.domain.entities.Tracker data class AppTrackersState( val appDesc: ApplicationDescription? = null, val isBlockingActivated: Boolean = false, - val trackersWithWhiteList: List>? = null, + val trackersWithBlockedList: List> = emptyList(), val leaked: Int = 0, val blocked: Int = 0, val isTrackersBlockingEnabled: Boolean = false, val isWhitelistEmpty: Boolean = true, - val showQuickPrivacyDisabledMessage: Boolean = false, ) { - fun getTrackersStatus(): List>? { - return trackersWithWhiteList?.map { it.first to !it.second } - } + fun getTrackersCount() = trackersWithBlockedList.size - fun getTrackersCount() = trackersWithWhiteList?.size ?: 0 - fun getBlockedTrackersCount(): Int = if (isTrackersBlockingEnabled && isBlockingActivated) - trackersWithWhiteList?.count { !it.second } ?: 0 - else 0 + fun getBlockedTrackersCount(): Int = trackersWithBlockedList.count { it.second } } 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 index 8740779..00ad365 100644 --- 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 @@ -24,9 +24,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import foundation.e.advancedprivacy.domain.entities.ApplicationDescription import foundation.e.advancedprivacy.domain.entities.TrackerMode +import foundation.e.advancedprivacy.domain.usecases.AppTrackersUseCase import foundation.e.advancedprivacy.domain.usecases.GetQuickPrivacyStateUseCase import foundation.e.advancedprivacy.domain.usecases.TrackersStateUseCase import foundation.e.advancedprivacy.domain.usecases.TrackersStatisticsUseCase +import foundation.e.advancedprivacy.features.trackers.URL_LEARN_MORE_ABOUT_TRACKERS import foundation.e.advancedprivacy.trackers.domain.entities.Tracker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow @@ -41,6 +43,7 @@ import kotlinx.coroutines.withContext class AppTrackersViewModel( private val app: ApplicationDescription, + private val appTrackersUseCase: AppTrackersUseCase, private val trackersStateUseCase: TrackersStateUseCase, private val trackersStatisticsUseCase: TrackersStatisticsUseCase, private val getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase @@ -56,17 +59,10 @@ class AppTrackersViewModel( 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) - ) - } + _state.update { + it.copy( + appDesc = app, + ) } } @@ -79,80 +75,71 @@ class AppTrackersViewModel( ).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() + fun onClickLearnMore() { + viewModelScope.launch { + _singleEvents.emit(SingleEvent.OpenUrl(Uri.parse(URL_LEARN_MORE_ABOUT_TRACKERS))) } } - private suspend fun blockAllToggleAction(action: Action.BlockAllToggleAction) { - withContext(Dispatchers.IO) { + fun onToggleBlockAll(isBlocked: Boolean) { + viewModelScope.launch(Dispatchers.IO) { if (!state.value.isTrackersBlockingEnabled) { _singleEvents.emit(SingleEvent.ToastTrackersControlDisabled) } - trackersStateUseCase.toggleAppWhitelist(app, !action.isBlocked) - _state.update { - it.copy( - isBlockingActivated = !trackersStateUseCase.isWhitelisted(app) - ) - } + appTrackersUseCase.toggleAppWhitelist(app, isBlocked) + updateWhitelist() } } - private suspend fun toggleTrackerAction(action: Action.ToggleTrackerAction) { - withContext(Dispatchers.IO) { + fun onToggleTracker(tracker: Tracker, isBlocked: Boolean) { + viewModelScope.launch(Dispatchers.IO) { if (!state.value.isTrackersBlockingEnabled) { _singleEvents.emit(SingleEvent.ToastTrackersControlDisabled) } - if (state.value.isBlockingActivated) { - trackersStateUseCase.blockTracker(app, action.tracker, action.isBlocked) - updateWhitelist() - } + trackersStateUseCase.blockTracker(app, tracker, 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) { - } - } + fun onClickTracker(tracker: Tracker) { + viewModelScope.launch(Dispatchers.IO) { + tracker.exodusId?.let { + runCatching { Uri.parse(exodusBaseUrl + it) }.getOrNull() + }?.let { _singleEvents.emit(SingleEvent.OpenUrl(it)) } } } - private suspend fun resetAllTrackers() { - withContext(Dispatchers.IO) { - trackersStateUseCase.clearWhitelist(app) + fun onClickResetAllTrackers() { + viewModelScope.launch(Dispatchers.IO) { + appTrackersUseCase.clearWhitelist(app) updateWhitelist() } } - private fun fetchStatistics() { - val (blocked, leaked) = trackersStatisticsUseCase.getCalls(app) - return _state.update { s -> + + private suspend fun fetchStatistics() = withContext(Dispatchers.IO) { + val (blocked, leaked) = appTrackersUseCase.getCalls(app) + val trackersWithBlockedList = appTrackersUseCase.getTrackersWithBlockedList(app) + + _state.update { s -> s.copy( - trackersWithWhiteList = trackersStatisticsUseCase.getTrackersWithWhiteList(app), leaked = leaked, blocked = blocked, - isWhitelistEmpty = trackersStatisticsUseCase.isWhiteListEmpty(app) + isBlockingActivated = !trackersStateUseCase.isWhitelisted(app), + isWhitelistEmpty = trackersStatisticsUseCase.isWhiteListEmpty(app), + trackersWithBlockedList = trackersWithBlockedList ) } } - private fun updateWhitelist() { + private suspend fun updateWhitelist() = withContext(Dispatchers.IO) { _state.update { s -> s.copy( - trackersWithWhiteList = trackersStatisticsUseCase.getTrackersWithWhiteList(app), - isWhitelistEmpty = trackersStatisticsUseCase.isWhiteListEmpty(app) + isBlockingActivated = !trackersStateUseCase.isWhitelisted(app), + trackersWithBlockedList = appTrackersUseCase.enrichWithBlockedState( + app, s.trackersWithBlockedList.map { it.first } + ), + isWhitelistEmpty = trackersStatisticsUseCase.isWhiteListEmpty(app), ) } } @@ -162,11 +149,4 @@ class AppTrackersViewModel( 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 index ef845b6..1d49905 100644 --- 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 @@ -1,4 +1,5 @@ /* + * Copyright (C) 2023 MURENA SAS * Copyright (C) 2021 E FOUNDATION * * This program is free software: you can redistribute it and/or modify @@ -20,72 +21,67 @@ 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.advancedprivacy.databinding.ApptrackersItemTrackerToggleBinding import foundation.e.advancedprivacy.trackers.domain.entities.Tracker class ToggleTrackersAdapter( - private val itemsLayout: Int, - private val onToggleSwitch: (Tracker, Boolean) -> Unit, - private val onClickTitle: (Tracker) -> Unit + private val viewModel: AppTrackersViewModel ) : RecyclerView.Adapter() { - - 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) + private val binding: ApptrackersItemTrackerToggleBinding, + private val viewModel: AppTrackersViewModel, + ) : RecyclerView.ViewHolder(binding.root) { - val toggle: Switch = view.findViewById(R.id.toggle) + fun bind(item: Pair) { + val label = item.first.label + with(binding.title) { + if (item.first.exodusId != null) { - fun bind(item: Pair, 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 + setTextColor(ContextCompat.getColor(context, R.color.accent)) + val spannable = SpannableString(label) + spannable.setSpan(UnderlineSpan(), 0, spannable.length, 0) + text = spannable + } else { + setTextColor(ContextCompat.getColor(context, R.color.primary_text)) + text = label + } + setOnClickListener { viewModel.onClickTracker(item.first) } } + with(binding.toggle) { + isChecked = item.second - toggle.isChecked = item.second - toggle.isEnabled = isEnabled - - toggle.setOnClickListener { - onToggleSwitch(item.first, toggle.isChecked) + setOnClickListener { + viewModel.onToggleTracker(item.first, isChecked) + } } - - title.setOnClickListener { onClickTitle(item.first) } } } private var dataSet: List> = emptyList() - fun updateDataSet(new: List>, isEnabled: Boolean) { - this.isEnabled = isEnabled + fun updateDataSet(new: List>) { 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) + return ViewHolder( + ApptrackersItemTrackerToggleBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ), + viewModel + ) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { val permission = dataSet[position] - holder.bind(permission, isEnabled) + holder.bind(permission) } override fun getItemCount(): Int = dataSet.size diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerAppsAdapter.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerAppsAdapter.kt new file mode 100644 index 0000000..d419677 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerAppsAdapter.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2023 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 . + */ + +package foundation.e.advancedprivacy.features.trackers.trackerdetails + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import foundation.e.advancedprivacy.databinding.ApptrackersItemTrackerToggleBinding +import foundation.e.advancedprivacy.domain.entities.ApplicationDescription + +class TrackerAppsAdapter( + private val viewModel: TrackerDetailsViewModel +) : RecyclerView.Adapter() { + + class ViewHolder( + private val binding: ApptrackersItemTrackerToggleBinding, + private val viewModel: TrackerDetailsViewModel, + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: Pair) { + val (app, isWhiteListed) = item + binding.title.text = app.label + binding.toggle.apply { + this.isChecked = isWhiteListed + setOnClickListener { + viewModel.onToggleUnblockApp(app, isChecked) + } + } + } + } + + private var dataSet: List> = emptyList() + + fun updateDataSet(new: List>) { + dataSet = new + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + ApptrackersItemTrackerToggleBinding.inflate(LayoutInflater.from(parent.context), parent, false), + viewModel + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val permission = dataSet[position] + holder.bind(permission) + } + + override fun getItemCount(): Int = dataSet.size +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerDetailsFragment.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerDetailsFragment.kt new file mode 100644 index 0000000..481c809 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerDetailsFragment.kt @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2023 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 . + */ + +package foundation.e.advancedprivacy.features.trackers.trackerdetails + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.core.content.ContextCompat.getColor +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.divider.MaterialDividerItemDecoration +import com.google.android.material.snackbar.Snackbar +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.common.NavToolbarFragment +import foundation.e.advancedprivacy.databinding.TrackerdetailsFragmentBinding +import foundation.e.advancedprivacy.features.trackers.setupDisclaimerBlock +import kotlinx.coroutines.launch +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf + +class TrackerDetailsFragment : NavToolbarFragment(R.layout.trackerdetails_fragment) { + + private val args: TrackerDetailsFragmentArgs by navArgs() + private val viewModel: TrackerDetailsViewModel by viewModel { parametersOf(args.trackerId) } + + private lateinit var binding: TrackerdetailsFragmentBinding + + override fun getTitle(): CharSequence { + return "" + } + + 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 = TrackerdetailsFragmentBinding.bind(view) + + binding.blockAllToggle.setOnClickListener { + viewModel.onToggleBlockAll(binding.blockAllToggle.isChecked) + } + + binding.apps.apply { + layoutManager = LinearLayoutManager(requireContext()) + setHasFixedSize(true) + addItemDecoration( + MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL).apply { + dividerColor = getColor(requireContext(), R.color.divider) + } + ) + adapter = TrackerAppsAdapter(viewModel) + } + + setupDisclaimerBlock(binding.disclaimerBlockTrackers.root, viewModel::onClickLearnMore) + + listenViewModel() + } + + private fun listenViewModel() { + with(viewLifecycleOwner) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect(::handleEvents) + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + render(viewModel.state.value) + viewModel.state.collect(::render) + } + } + } + } + + private fun handleEvents(event: TrackerDetailsViewModel.SingleEvent) { + when (event) { + is TrackerDetailsViewModel.SingleEvent.ErrorEvent -> + displayToast(getString(event.errorResId)) + is TrackerDetailsViewModel.SingleEvent.ToastTrackersControlDisabled -> + Snackbar.make( + binding.root, + R.string.apptrackers_tracker_control_disabled_message, + Snackbar.LENGTH_LONG + ).show() + is TrackerDetailsViewModel.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() + } + } + } + } + + private fun render(state: TrackerDetailsState) { + setTitle(state.tracker?.label) + binding.subtitle.text = getString(R.string.trackerdetails_subtitle, state.tracker?.label) + binding.dataAppCount.apply { + primaryMessage.setText(R.string.trackerdetails_app_count_primary) + number.text = state.detectedCount.toString() + secondaryMessage.setText(R.string.trackerdetails_app_count_secondary) + } + + binding.dataBlockedLeaks.apply { + primaryMessage.setText(R.string.trackerdetails_blocked_leaks_primary) + number.text = state.blockedCount.toString() + secondaryMessage.text = getString(R.string.trackerdetails_blocked_leaks_secondary, state.leakedCount.toString()) + } + + binding.blockAllToggle.isChecked = state.isBlockAllActivated + + binding.apps.post { + (binding.apps.adapter as TrackerAppsAdapter?)?.updateDataSet(state.appList) + } + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerDetailsState.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerDetailsState.kt new file mode 100644 index 0000000..9ae7412 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerDetailsState.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2023 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 . + */ + +package foundation.e.advancedprivacy.features.trackers.trackerdetails + +import foundation.e.advancedprivacy.domain.entities.ApplicationDescription +import foundation.e.advancedprivacy.trackers.domain.entities.Tracker + +data class TrackerDetailsState( + val tracker: Tracker? = null, + val isBlockAllActivated: Boolean = false, + val detectedCount: Int = 0, + val blockedCount: Int = 0, + val leakedCount: Int = 0, + val appList: List> = emptyList(), + val isTrackersBlockingEnabled: Boolean = false, +) diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerDetailsViewModel.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerDetailsViewModel.kt new file mode 100644 index 0000000..91a1f2a --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/trackerdetails/TrackerDetailsViewModel.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2023 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 . + */ + +package foundation.e.advancedprivacy.features.trackers.trackerdetails + +import android.net.Uri +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import foundation.e.advancedprivacy.domain.entities.ApplicationDescription +import foundation.e.advancedprivacy.domain.entities.TrackerMode +import foundation.e.advancedprivacy.domain.usecases.GetQuickPrivacyStateUseCase +import foundation.e.advancedprivacy.domain.usecases.TrackerDetailsUseCase +import foundation.e.advancedprivacy.domain.usecases.TrackersStateUseCase +import foundation.e.advancedprivacy.domain.usecases.TrackersStatisticsUseCase +import foundation.e.advancedprivacy.features.trackers.URL_LEARN_MORE_ABOUT_TRACKERS +import foundation.e.advancedprivacy.trackers.domain.entities.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 TrackerDetailsViewModel( + private val tracker: Tracker, + private val trackersStateUseCase: TrackersStateUseCase, + private val trackersStatisticsUseCase: TrackersStatisticsUseCase, + private val trackerDetailsUseCase: TrackerDetailsUseCase, + private val getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase +) : ViewModel() { + private val _state = MutableStateFlow(TrackerDetailsState(tracker = tracker)) + val state = _state.asStateFlow() + + private val _singleEvents = MutableSharedFlow() + val singleEvents = _singleEvents.asSharedFlow() + + 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 onToggleUnblockApp(app: ApplicationDescription, isBlocked: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + if (!state.value.isTrackersBlockingEnabled) { + _singleEvents.emit(SingleEvent.ToastTrackersControlDisabled) + } + + trackersStateUseCase.blockTracker(app, tracker, isBlocked) + updateWhitelist() + } + } + + fun onToggleBlockAll(isBlocked: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + if (!state.value.isTrackersBlockingEnabled) { + _singleEvents.emit(SingleEvent.ToastTrackersControlDisabled) + } + trackerDetailsUseCase.toggleTrackerWhitelist(tracker, isBlocked) + _state.update { + it.copy( + isBlockAllActivated = !trackersStateUseCase.isWhitelisted(tracker) + ) + } + updateWhitelist() + } + } + + fun onClickLearnMore() { + viewModelScope.launch { + _singleEvents.emit(SingleEvent.OpenUrl(Uri.parse(URL_LEARN_MORE_ABOUT_TRACKERS))) + } + } + + private suspend fun fetchStatistics() = withContext(Dispatchers.IO) { + val (blocked, leaked) = trackerDetailsUseCase.getCalls(tracker) + val appsWhitWhiteListState = trackerDetailsUseCase.getAppsWithBlockedState(tracker) + + _state.update { s -> + s.copy( + isBlockAllActivated = !trackersStateUseCase.isWhitelisted(tracker), + detectedCount = appsWhitWhiteListState.size, + blockedCount = blocked, + leakedCount = leaked, + appList = appsWhitWhiteListState, + ) + } + } + + private suspend fun updateWhitelist() { + _state.update { s -> + s.copy( + isBlockAllActivated = !trackersStateUseCase.isWhitelisted(tracker), + appList = trackerDetailsUseCase.enrichWithBlockedState( + s.appList.map { it.first }, tracker + ) + ) + } + } + + sealed class SingleEvent { + data class ErrorEvent(@StringRes val errorResId: Int) : SingleEvent() + object ToastTrackersControlDisabled : SingleEvent() + data class OpenUrl(val url: Uri) : SingleEvent() + } +} diff --git a/app/src/main/res/drawable/bg_stroke_rounded_12.xml b/app/src/main/res/drawable/bg_stroke_rounded_12.xml new file mode 100644 index 0000000..d9c839c --- /dev/null +++ b/app/src/main/res/drawable/bg_stroke_rounded_12.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shield_alert.xml b/app/src/main/res/drawable/ic_shield_alert.xml new file mode 100644 index 0000000..9c20541 --- /dev/null +++ b/app/src/main/res/drawable/ic_shield_alert.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/pill_shape_tab_bg.xml b/app/src/main/res/drawable/pill_shape_tab_bg.xml new file mode 100644 index 0000000..5ef1de5 --- /dev/null +++ b/app/src/main/res/drawable/pill_shape_tab_bg.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/pill_shape_tab_indicator.xml b/app/src/main/res/drawable/pill_shape_tab_indicator.xml new file mode 100644 index 0000000..344a049 --- /dev/null +++ b/app/src/main/res/drawable/pill_shape_tab_indicator.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pill_shape_tab_selected.xml b/app/src/main/res/drawable/pill_shape_tab_selected.xml new file mode 100644 index 0000000..520d985 --- /dev/null +++ b/app/src/main/res/drawable/pill_shape_tab_selected.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/apptrackers_fragment.xml b/app/src/main/res/layout/apptrackers_fragment.xml index d0a72d5..06b8d3f 100644 --- a/app/src/main/res/layout/apptrackers_fragment.xml +++ b/app/src/main/res/layout/apptrackers_fragment.xml @@ -15,18 +15,15 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - - - - + android:background="@color/background" + android:layout_height="match_parent" + android:layout_width="match_parent" + > @@ -37,80 +34,137 @@ app:layout_behavior="@string/appbar_scrolling_view_behavior" > - - + android:orientation="vertical" + android:layout_marginHorizontal="16dp" + android:layout_marginVertical="32dp" + > + + + + + + + - + + + + + + + + + - - - - - - - - \ No newline at end of file + diff --git a/app/src/main/res/layout/apptrackers_item_tracker_toggle.xml b/app/src/main/res/layout/apptrackers_item_tracker_toggle.xml index db7086f..753e734 100644 --- a/app/src/main/res/layout/apptrackers_item_tracker_toggle.xml +++ b/app/src/main/res/layout/apptrackers_item_tracker_toggle.xml @@ -1,12 +1,26 @@ + + + + diff --git a/app/src/main/res/layout/fragment_fake_location.xml b/app/src/main/res/layout/fragment_fake_location.xml index 3c709e9..5da95e1 100644 --- a/app/src/main/res/layout/fragment_fake_location.xml +++ b/app/src/main/res/layout/fragment_fake_location.xml @@ -1,4 +1,21 @@ + + - - + - - - - + + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/highlight_data_number.xml b/app/src/main/res/layout/highlight_data_number.xml new file mode 100644 index 0000000..7938165 --- /dev/null +++ b/app/src/main/res/layout/highlight_data_number.xml @@ -0,0 +1,58 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/trackerdetails_fragment.xml b/app/src/main/res/layout/trackerdetails_fragment.xml new file mode 100644 index 0000000..45ba0e4 --- /dev/null +++ b/app/src/main/res/layout/trackerdetails_fragment.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/trackers_item_app.xml b/app/src/main/res/layout/trackers_item_app.xml index 6af43ea..883a4da 100644 --- a/app/src/main/res/layout/trackers_item_app.xml +++ b/app/src/main/res/layout/trackers_item_app.xml @@ -1,20 +1,36 @@ + + + + diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 52a1677..1047da6 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -61,6 +61,9 @@ android:id="@+id/goto_appTrackersFragment" app:destination="@id/appTrackersFragment" /> + + + + + @color/e_action_bar @@ -10,14 +27,16 @@ @color/e_background @color/e_alpha_base + @color/e_disabled_color + @color/e_divider_color + + @color/e_background_overlay #263238 #FFFFFFFF - @color/e_disabled_color #28C97C #F8432E #AADCFE - @color/e_background_overlay @color/e_primary_text_color_dark diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ba3ba03..aa33837 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,21 @@ + Advanced Privacy @@ -80,25 +97,55 @@ 24 hours past month past year - Manage trackers used in applications * : + @string/ipscrambling_app_list_infos + Trackers Activity Summary + Apps + Trackers + + %s trackers detected + detected in %s apps + HH:mm MMMM d - EEE MMMM yyyy - %1$d/%2$d blocked trackers, %3$d leaks + - Block trackers - Opt for the trackers you want to activate/deactivate. + %s tracking summary + Total + Detected trackers + Blocked + Trackers + Blocked leaks + %s allowed leaks + Manage tracker + Toggle on trackers control + Toggle off the trackers you want to allow: No trackers were detected yet. If new trackers are detected they will be updated here. No trackers were detected yet. All future trackers will be blocked. No trackers were detected yet. Some trackers were unblocked previously. Enable Quick Privacy to be able to activate/deactivate trackers. - %1$d blocked trackers out of %2$d detected trackers, %3$d blocked leaks and %4$d allowed leaks. App not installed. Changes will take effect when tracker blocker is on. Reset trackers + + %s tracking summary + Detected in + Different applications + Blocked leaks + %s allowed leaks + Manage tracker + Block this tracker across all apps + Toggle off the apps for which you want to allow this tracker: + + + Note: + in some rare cases, disabling tracker can cause some apps to malfunction. You can choose specifically which trackers you want to block. + Know more. + + Do not show again Trackers control diff --git a/core/src/main/java/foundation/e/advancedprivacy/data/repositories/AppListsRepository.kt b/core/src/main/java/foundation/e/advancedprivacy/data/repositories/AppListsRepository.kt index f29bb8a..b44e96e 100644 --- a/core/src/main/java/foundation/e/advancedprivacy/data/repositories/AppListsRepository.kt +++ b/core/src/main/java/foundation/e/advancedprivacy/data/repositories/AppListsRepository.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext class AppListsRepository( private val permissionsModule: IPermissionsPrivacyModule, @@ -179,20 +180,24 @@ class AppListsRepository( } else test(app) } - fun applyForHiddenApps(app: ApplicationDescription, action: (ApplicationDescription) -> Unit) { + fun getRealApps(app: ApplicationDescription): List { + return when (app) { + dummySystemApp -> getHiddenSystemApps() + dummyCompatibilityApp -> getCompatibilityApps() + else -> listOf(app) + } + } + + suspend fun applyForHiddenApps(app: ApplicationDescription, action: suspend (ApplicationDescription) -> Unit) { mapReduceForHiddenApps(app = app, map = action, reduce = {}) } - fun mapReduceForHiddenApps( + suspend fun mapReduceForHiddenApps( app: ApplicationDescription, - map: (ApplicationDescription) -> T, - reduce: (List) -> R + map: suspend (ApplicationDescription) -> T, + reduce: suspend (List) -> R ): R { - return if (app == dummySystemApp) { - reduce(getHiddenSystemApps().map(map)) - } else if (app == dummyCompatibilityApp) { - reduce(getCompatibilityApps().map(map)) - } else reduce(listOf(map(app))) + return reduce(getRealApps(app).map { map(it) }) } private var appsByUid = mapOf() @@ -214,6 +219,16 @@ class AppListsRepository( } } + suspend fun getDisplayableApp(apId: String): ApplicationDescription? = withContext(Dispatchers.IO) { + getApp(apId)?.let { app -> + when { + app in getCompatibilityApps() -> dummyCompatibilityApp + app in getHiddenSystemApps() -> dummySystemApp + else -> app + } + } + } + private val allProfilesAppDescriptions = MutableStateFlow( Triple( emptyList(), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5c945fa..0bb32ba 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ androidx-room-common = { group = "androidx.room", name = "room-common", version. androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidx-room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "androidx-room" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidx-room" } +androidx-viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version = "1.0.0" } androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version = "2.7.1" } eos-elib = { group = "foundation.e", name = "elib", version = "0.0.1-alpha11" } eos-orbotservice = { group = "foundation.e", name = "orbotservice", version.ref = "orbotservice" } diff --git a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/StatsDatabase.kt b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/StatsDatabase.kt index 15ff813..a80d4dc 100644 --- a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/StatsDatabase.kt +++ b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/StatsDatabase.kt @@ -224,61 +224,112 @@ class StatsDatabase( } } - fun getContactedTrackersCountByAppId(): Map { + fun getCalls(appId: String, periodCount: Int, periodUnit: TemporalUnit): Pair { synchronized(lock) { + val minTimestamp = getPeriodStartTs(periodCount, periodUnit) val db = readableDatabase - val projection = "$COLUMN_NAME_APPID, $COLUMN_NAME_TRACKER" + val selection = "$COLUMN_NAME_APPID = ? AND " + + "$COLUMN_NAME_TIMESTAMP >= ?" + val selectionArg = arrayOf("" + appId, "" + minTimestamp) + val projection = + "SUM($COLUMN_NAME_NUMBER_CONTACTED) $PROJECTION_NAME_CONTACTED_SUM," + + "SUM($COLUMN_NAME_NUMBER_BLOCKED) $PROJECTION_NAME_BLOCKED_SUM" val cursor = db.rawQuery( - "SELECT DISTINCT $projection FROM $TABLE_NAME", // + - arrayOf() + "SELECT $projection FROM $TABLE_NAME WHERE $selection", + selectionArg ) - val countByApp = mutableMapOf() - while (cursor.moveToNext()) { - trackersRepository.getTracker(cursor.getString(COLUMN_NAME_TRACKER))?.let { - val appId = cursor.getString(COLUMN_NAME_APPID) - countByApp[appId] = countByApp.getOrDefault(appId, 0) + 1 - } + var calls: Pair = 0 to 0 + if (cursor.moveToNext()) { + val contacted = cursor.getInt(PROJECTION_NAME_CONTACTED_SUM) + val blocked = cursor.getInt(PROJECTION_NAME_BLOCKED_SUM) + calls = blocked to contacted - blocked } cursor.close() db.close() - return countByApp + return calls } } - fun getCallsByAppIds(periodCount: Int, periodUnit: TemporalUnit): Map> { + fun getMostLeakedAppId(periodCount: Int, periodUnit: TemporalUnit): String { synchronized(lock) { val minTimestamp = getPeriodStartTs(periodCount, periodUnit) val db = readableDatabase val selection = "$COLUMN_NAME_TIMESTAMP >= ?" val selectionArg = arrayOf("" + minTimestamp) val projection = "$COLUMN_NAME_APPID, " + - "SUM($COLUMN_NAME_NUMBER_CONTACTED) $PROJECTION_NAME_CONTACTED_SUM," + - "SUM($COLUMN_NAME_NUMBER_BLOCKED) $PROJECTION_NAME_BLOCKED_SUM" + "SUM($COLUMN_NAME_NUMBER_CONTACTED - $COLUMN_NAME_NUMBER_BLOCKED) $PROJECTION_NAME_LEAKED_SUM" val cursor = db.rawQuery( "SELECT $projection FROM $TABLE_NAME" + " WHERE $selection" + - " GROUP BY $COLUMN_NAME_APPID", + " GROUP BY $COLUMN_NAME_APPID" + + " ORDER BY $PROJECTION_NAME_LEAKED_SUM DESC LIMIT 1", selectionArg ) - val callsByApp = HashMap>() + var appId = "" + if (cursor.moveToNext()) { + appId = cursor.getString(COLUMN_NAME_APPID) + } + cursor.close() + db.close() + return appId + } + } + + fun getDistinctTrackerAndApp(): List> { + synchronized(lock) { + val db = readableDatabase + val projection = "$COLUMN_NAME_APPID, $COLUMN_NAME_TRACKER" + val cursor = db.rawQuery( + "SELECT DISTINCT $projection FROM $TABLE_NAME", // + + arrayOf() + ) + + val res = mutableListOf>() while (cursor.moveToNext()) { - val contacted = cursor.getInt(PROJECTION_NAME_CONTACTED_SUM) - val blocked = cursor.getInt(PROJECTION_NAME_BLOCKED_SUM) - callsByApp[cursor.getString(COLUMN_NAME_APPID)] = blocked to contacted - blocked + res.add( + cursor.getString(COLUMN_NAME_TRACKER) to cursor.getString(COLUMN_NAME_APPID) + ) } cursor.close() db.close() - return callsByApp + return res } } - fun getCalls(appId: String, periodCount: Int, periodUnit: TemporalUnit): Pair { + suspend fun getApIds(trackerId: String): List = withContext(Dispatchers.IO) { synchronized(lock) { - val minTimestamp = getPeriodStartTs(periodCount, periodUnit) val db = readableDatabase - val selection = "$COLUMN_NAME_APPID = ? AND " + - "$COLUMN_NAME_TIMESTAMP >= ?" - val selectionArg = arrayOf("" + appId, "" + minTimestamp) + val columns = arrayOf(COLUMN_NAME_APPID, COLUMN_NAME_TRACKER) + val selection = "$COLUMN_NAME_TRACKER = ?" + val selectionArg = arrayOf(trackerId) + val cursor = db.query( + true, + TABLE_NAME, + columns, + selection, + selectionArg, + null, + null, + null, + null + ) + + val apIds: MutableList = ArrayList() + while (cursor.moveToNext()) { + apIds.add(cursor.getString(COLUMN_NAME_APPID)) + } + cursor.close() + db.close() + + apIds + } + } + + suspend fun getCallsForApp(apId: String): Pair = withContext(Dispatchers.IO) { + synchronized(lock) { + val db = readableDatabase + val selection = "$COLUMN_NAME_APPID = ?" + val selectionArg = arrayOf(apId) val projection = "SUM($COLUMN_NAME_NUMBER_CONTACTED) $PROJECTION_NAME_CONTACTED_SUM," + "SUM($COLUMN_NAME_NUMBER_BLOCKED) $PROJECTION_NAME_BLOCKED_SUM" @@ -294,32 +345,31 @@ class StatsDatabase( } cursor.close() db.close() - return calls + calls } } - fun getMostLeakedAppId(periodCount: Int, periodUnit: TemporalUnit): String { + suspend fun getCallsForTracker(trackerId: String): Pair = withContext(Dispatchers.IO) { synchronized(lock) { - val minTimestamp = getPeriodStartTs(periodCount, periodUnit) val db = readableDatabase - val selection = "$COLUMN_NAME_TIMESTAMP >= ?" - val selectionArg = arrayOf("" + minTimestamp) - val projection = "$COLUMN_NAME_APPID, " + - "SUM($COLUMN_NAME_NUMBER_CONTACTED - $COLUMN_NAME_NUMBER_BLOCKED) $PROJECTION_NAME_LEAKED_SUM" + val selection = "$COLUMN_NAME_TRACKER = ?" + val selectionArg = arrayOf(trackerId) + val projection = + "SUM($COLUMN_NAME_NUMBER_CONTACTED) $PROJECTION_NAME_CONTACTED_SUM," + + "SUM($COLUMN_NAME_NUMBER_BLOCKED) $PROJECTION_NAME_BLOCKED_SUM" val cursor = db.rawQuery( - "SELECT $projection FROM $TABLE_NAME" + - " WHERE $selection" + - " GROUP BY $COLUMN_NAME_APPID" + - " ORDER BY $PROJECTION_NAME_LEAKED_SUM DESC LIMIT 1", + "SELECT $projection FROM $TABLE_NAME WHERE $selection", selectionArg ) - var appId = "" + var calls: Pair = 0 to 0 if (cursor.moveToNext()) { - appId = cursor.getString(COLUMN_NAME_APPID) + val contacted = cursor.getInt(PROJECTION_NAME_CONTACTED_SUM) + val blocked = cursor.getInt(PROJECTION_NAME_BLOCKED_SUM) + calls = blocked to contacted - blocked } cursor.close() db.close() - return appId + calls } } @@ -386,7 +436,40 @@ class StatsDatabase( return entry } - fun getTrackers(appIds: List?): List { + suspend fun getTrackerIds(appIds: List?): List = withContext(Dispatchers.IO) { + synchronized(lock) { + val columns = arrayOf(COLUMN_NAME_TRACKER, COLUMN_NAME_APPID) + var selection: String? = null + + var selectionArg: Array? = null + appIds?.let { appIds -> + selection = "$COLUMN_NAME_APPID IN (${appIds.joinToString(", ") { "'$it'" }})" + selectionArg = arrayOf() + } + + val db = readableDatabase + val cursor = db.query( + true, + TABLE_NAME, + columns, + selection, + selectionArg, + null, + null, + null, + null + ) + val trackerIds: MutableList = mutableListOf() + while (cursor.moveToNext()) { + trackerIds.add(cursor.getString(COLUMN_NAME_TRACKER)) + } + cursor.close() + db.close() + trackerIds + } + } + + suspend fun getTrackers(appIds: List?): List = withContext(Dispatchers.IO) { synchronized(lock) { val columns = arrayOf(COLUMN_NAME_TRACKER, COLUMN_NAME_APPID) var selection: String? = null @@ -419,7 +502,7 @@ class StatsDatabase( } cursor.close() db.close() - return trackers + trackers } } diff --git a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/WhitelistRepository.kt b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/WhitelistRepository.kt index 429c5e9..9f37a1d 100644 --- a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/WhitelistRepository.kt +++ b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/data/WhitelistRepository.kt @@ -23,6 +23,9 @@ import android.content.SharedPreferences import foundation.e.advancedprivacy.data.repositories.AppListsRepository import foundation.e.advancedprivacy.domain.entities.ApplicationDescription import foundation.e.advancedprivacy.trackers.domain.entities.Tracker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import java.io.File class WhitelistRepository( @@ -32,18 +35,25 @@ class WhitelistRepository( private var appsWhitelist: Set = HashSet() private var appUidsWhitelist: Set = HashSet() - private var trackersWhitelistByApp: MutableMap> = HashMap() - private var trackersWhitelistByUid: Map> = HashMap() + private var trackersWhitelist: Set = HashSet() + + private var apIdTrackersWhitelist: Map = emptyMap() + private var appUidTrackersWhitelist: Map = emptyMap() private val prefs: SharedPreferences companion object { - private const val SHARED_PREFS_FILE = "trackers_whitelist_v2" + private const val SHARED_PREFS_FILE = "trackers_whitelist_v3" private const val KEY_BLOCKING_ENABLED = "blocking_enabled" private const val KEY_APPS_WHITELIST = "apps_whitelist" - private const val KEY_APP_TRACKERS_WHITELIST_PREFIX = "app_trackers_whitelist_" + private const val KEY_TRACKERS_WHITELIST = "trackers_whitelist" + private const val KEY_APP_TRACKER_WHITELIST = "app_tracker_whitelist" + private const val KEY_APP_TRACKER_BLACKLIST = "app_tracker_blacklist" + // Deprecated keys. private const val SHARED_PREFS_FILE_V1 = "trackers_whitelist.prefs" + private const val SHARED_PREFS_FILE_V2 = "trackers_whitelist_v2" + private const val KEY_APP_TRACKERS_WHITELIST_PREFIX = "app_trackers_whitelist_" } init { @@ -56,6 +66,9 @@ class WhitelistRepository( if (context.sharedPreferencesExists(SHARED_PREFS_FILE_V1)) { migrate1To2(context) } + if (context.sharedPreferencesExists(SHARED_PREFS_FILE_V2)) { + migrate2To3(context) + } } private fun Context.sharedPreferencesExists(fileName: String): Boolean { @@ -86,7 +99,7 @@ class WhitelistRepository( val apId = appListsRepository.getApp(uid)?.apId apId?.let { val trackers = prefsV1.getStringSet(key, emptySet()) - editorV2.putStringSet(buildAppTrackersKey(apId), trackers) + editorV2.putStringSet(KEY_APP_TRACKERS_WHITELIST_PREFIX + apId, trackers) } } catch (e: Exception) { } } @@ -98,10 +111,39 @@ class WhitelistRepository( reloadCache() } + private fun migrate2To3(context: Context) { + val prefsV2 = context.getSharedPreferences(SHARED_PREFS_FILE_V1, Context.MODE_PRIVATE) + val editorV3 = prefs.edit() + + editorV3.putBoolean(KEY_BLOCKING_ENABLED, prefsV2.getBoolean(KEY_BLOCKING_ENABLED, false)) + + prefsV2.getStringSet(KEY_APPS_WHITELIST, null)?.let { + editorV3.putStringSet(KEY_APPS_WHITELIST, it) + } + editorV3.commit() + + runBlocking { + prefsV2.all.keys.forEach { key -> + if (key.startsWith(KEY_APP_TRACKERS_WHITELIST_PREFIX)) { + runCatching { + val apId = key.substring(KEY_APP_TRACKERS_WHITELIST_PREFIX.length) + prefsV2.getStringSet(key, null) + ?.map { trackerId -> buildApIdTrackerKey(apId, trackerId) } + ?.let { setWhitelisted(it, true) } + } + } + } + } + + context.deleteSharedPreferences(SHARED_PREFS_FILE_V2) + + reloadCache() + } + private fun reloadCache() { isBlockingEnabled = prefs.getBoolean(KEY_BLOCKING_ENABLED, false) reloadAppsWhiteList() - reloadAllAppTrackersWhiteList() + reloadAppTrackersWhitelist() } private fun reloadAppsWhiteList() { @@ -111,24 +153,28 @@ class WhitelistRepository( .toSet() } - private fun refreshAppUidTrackersWhiteList() { - trackersWhitelistByUid = trackersWhitelistByApp.mapNotNull { (apId, value) -> + private fun reloadTrackersWhiteList() { + trackersWhitelist = prefs.getStringSet(KEY_TRACKERS_WHITELIST, HashSet()) ?: HashSet() + } + + private fun reloadAppTrackersWhitelist() { + val whitelist = mutableMapOf() + prefs.getStringSet(KEY_APP_TRACKER_WHITELIST, HashSet())?.forEach { key -> + whitelist[key] = true + } + + prefs.getStringSet(KEY_APP_TRACKER_BLACKLIST, HashSet())?.forEach { key -> + whitelist[key] = false + } + + apIdTrackersWhitelist = whitelist + appUidTrackersWhitelist = whitelist.mapNotNull { (apIdTrackerId, isWhitelisted) -> + val (apId, tracker) = parseApIdTrackerKey(apIdTrackerId) appListsRepository.getApp(apId)?.uid?.let { uid -> - uid to value + buildAppUidTrackerKey(uid, tracker) to isWhitelisted } }.toMap() } - private fun reloadAllAppTrackersWhiteList() { - val map: MutableMap> = HashMap() - prefs.all.keys.forEach { key -> - if (key.startsWith(KEY_APP_TRACKERS_WHITELIST_PREFIX)) { - map[key.substring(KEY_APP_TRACKERS_WHITELIST_PREFIX.length)] = ( - prefs.getStringSet(key, HashSet()) ?: HashSet() - ) - } - } - trackersWhitelistByApp = map - } var isBlockingEnabled: Boolean = false get() = field @@ -149,34 +195,83 @@ class WhitelistRepository( reloadAppsWhiteList() } - private fun buildAppTrackersKey(apId: String): String { - return KEY_APP_TRACKERS_WHITELIST_PREFIX + apId - } + private suspend fun setWhitelisted(keys: List, isWhitelisted: Boolean) = withContext(Dispatchers.IO) { + val whitelist = HashSet().apply { + prefs.getStringSet(KEY_APP_TRACKER_WHITELIST, HashSet())?.let { addAll(it) } + } - fun setWhiteListed(tracker: Tracker, apId: String, isWhiteListed: Boolean) { - val trackers = trackersWhitelistByApp.getOrDefault(apId, HashSet()) - trackersWhitelistByApp[apId] = trackers + val blacklist = HashSet().apply { + prefs.getStringSet(KEY_APP_TRACKER_BLACKLIST, HashSet())?.let { addAll(it) } + } - if (isWhiteListed) { - trackers.add(tracker.id) + if (isWhitelisted) { + blacklist.removeAll(keys) + whitelist.addAll(keys) } else { - trackers.remove(tracker.id) + whitelist.removeAll(keys) + blacklist.addAll(keys) } - refreshAppUidTrackersWhiteList() - prefs.edit().putStringSet(buildAppTrackersKey(apId), trackers).commit() + + prefs.edit().apply { + putStringSet(KEY_APP_TRACKER_BLACKLIST, blacklist) + putStringSet(KEY_APP_TRACKER_WHITELIST, whitelist) + commit() + } + reloadAppTrackersWhitelist() + } + + suspend fun setWhiteListed(tracker: Tracker, apId: String, isWhitelisted: Boolean) { + setWhitelisted(listOf(buildApIdTrackerKey(apId, tracker.id)), isWhitelisted) + } + + suspend fun setWhitelistedTrackersForApp(apId: String, trackerIds: List, isWhitelisted: Boolean) = withContext( + Dispatchers.IO + ) { + setWhitelisted( + trackerIds.map { trackerId -> buildApIdTrackerKey(apId, trackerId) }, isWhitelisted + ) + } + + suspend fun setWhitelistedAppsForTracker(apIds: List, trackerId: String, isWhitelisted: Boolean) = withContext( + Dispatchers.IO + ) { + setWhitelisted( + apIds.map { apId -> buildApIdTrackerKey(apId, trackerId) }, + isWhitelisted + ) } fun isAppWhiteListed(app: ApplicationDescription): Boolean { return appsWhitelist.contains(app.apId) } - fun isWhiteListed(appUid: Int, trackerId: String?): Boolean { - return appUidsWhitelist.contains(appUid) || - trackersWhitelistByUid.getOrDefault(appUid, HashSet()).contains(trackerId) + fun isAppWhiteListed(appUid: Int): Boolean { + return appUidsWhitelist.contains(appUid) + } + + fun isWhiteListed(appUid: Int, trackerId: String?): Boolean? { + trackerId ?: return null + + val key = buildAppUidTrackerKey(appUid, trackerId) + return appUidTrackersWhitelist.get(key) + } + + private fun buildApIdTrackerKey(apId: String, trackerId: String): String { + return "$apId|$trackerId" + } + + private fun parseApIdTrackerKey(key: String): Pair { + return key.split("|").let { it[0] to it[1] } + } + + private fun buildAppUidTrackerKey(appUid: Int, trackerId: String): String { + return "$appUid-$trackerId" } fun areWhiteListEmpty(): Boolean { - return appsWhitelist.isEmpty() && trackersWhitelistByApp.all { (_, trackers) -> trackers.isEmpty() } + return appsWhitelist.isEmpty() && + trackersWhitelist.isEmpty() && + apIdTrackersWhitelist.values.none { it } } fun getWhiteListedApp(): List { @@ -184,12 +279,64 @@ class WhitelistRepository( } fun getWhiteListForApp(app: ApplicationDescription): List { - return trackersWhitelistByApp[app.apId]?.toList() ?: emptyList() + return apIdTrackersWhitelist.entries.mapNotNull { (key, isWhitelisted) -> + if (!isWhitelisted) { + null + } else { + val (apId, tracker) = parseApIdTrackerKey(key) + if (apId == app.apId) { + tracker + } else { + null + } + } + } } fun clearWhiteList(apId: String) { - trackersWhitelistByApp.remove(apId) - refreshAppUidTrackersWhiteList() - prefs.edit().remove(buildAppTrackersKey(apId)).commit() + val (whitelistToRemove, blacklistToRemove) = apIdTrackersWhitelist.entries + .filter { (key, _) -> key.startsWith(apId) } + .partition { (_, whitelisted) -> whitelisted }.let { (whitelistEntries, blacklistEntries) -> + whitelistEntries.map { it.key }.toSet() to + blacklistEntries.map { it.key }.toSet() + } + + val whitelist = HashSet().apply { + prefs.getStringSet(KEY_APP_TRACKER_WHITELIST, HashSet())?.let { addAll(it) } + } + + val blacklist = HashSet().apply { + prefs.getStringSet(KEY_APP_TRACKER_BLACKLIST, HashSet())?.let { addAll(it) } + } + + whitelist.removeAll(whitelistToRemove) + blacklist.removeAll(blacklistToRemove) + + prefs.edit().apply { + putStringSet(KEY_APP_TRACKER_WHITELIST, whitelist) + putStringSet(KEY_APP_TRACKER_BLACKLIST, blacklist) + commit() + } + reloadAppTrackersWhitelist() + } + + fun setWhiteListed(tracker: Tracker, isWhiteListed: Boolean) { + val current = prefs.getStringSet(KEY_TRACKERS_WHITELIST, HashSet())?.toHashSet() ?: HashSet() + + if (isWhiteListed) { + current.add(tracker.id) + } else { + current.remove(tracker.id) + } + prefs.edit().putStringSet(KEY_TRACKERS_WHITELIST, current).commit() + reloadTrackersWhiteList() + } + + fun isWhiteListed(tracker: Tracker): Boolean { + return trackersWhitelist.contains(tracker.id) + } + + fun isTrackerWhiteListed(trackerId: String): Boolean { + return trackersWhitelist.contains(trackerId) } } diff --git a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/FilterHostnameUseCase.kt b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/FilterHostnameUseCase.kt index e229cab..e0fae43 100644 --- a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/FilterHostnameUseCase.kt +++ b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/FilterHostnameUseCase.kt @@ -81,7 +81,15 @@ class FilterHostnameUseCase( private fun shouldBlock(appUid: Int, trackerId: String?): Boolean { return whitelistRepository.isBlockingEnabled && - !whitelistRepository.isWhiteListed(appUid, trackerId) + trackerId != null && + !isWhitelisted(appUid, trackerId) + } + + fun isWhitelisted(appUid: Int, trackerId: String): Boolean { + return whitelistRepository.isWhiteListed(appUid, trackerId) ?: ( + whitelistRepository.isTrackerWhiteListed(trackerId) || + whitelistRepository.isAppWhiteListed(appUid) + ) } private val queue = LinkedBlockingQueue() diff --git a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/StatisticsUseCase.kt b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/StatisticsUseCase.kt index e7a84b8..22bd8fc 100644 --- a/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/StatisticsUseCase.kt +++ b/trackers/src/main/java/foundation/e/advancedprivacy/trackers/domain/usecases/StatisticsUseCase.kt @@ -39,25 +39,14 @@ class StatisticsUseCase( return database.getActiveTrackersByPeriod(periodsCount, periodUnit) } - fun getContactedTrackersCountByApp(): Map { - return database.getContactedTrackersCountByAppId().mapByAppIdToApp() - } - fun getContactedTrackersCount(): Int { return database.getContactedTrackersCount() } - fun getTrackers(apps: List?): List { + suspend fun getTrackers(apps: List?): List { return database.getTrackers(apps?.map { it.apId }) } - fun getCallsByApps( - periodCount: Int, - periodUnit: TemporalUnit - ): Map> { - return database.getCallsByAppIds(periodCount, periodUnit).mapByAppIdToApp() - } - fun getCalls(app: ApplicationDescription, periodCount: Int, periodUnit: TemporalUnit): Pair { return database.getCalls(app.apId, periodCount, periodUnit) } -- cgit v1.2.1