diff options
Diffstat (limited to 'app/src/main/java')
22 files changed, 1264 insertions, 404 deletions
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<IPermissionsPrivacyModule> { 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/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 <https://www.gnu.org/licenses/>. + */ +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 <https://www.gnu.org/licenses/>. + */ +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<Int, Int> { + 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<Pair<Tracker, Boolean>> { + 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<Tracker>): List<Pair<Tracker, Boolean>> { + 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 <https://www.gnu.org/licenses/>. + */ +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<Pair<ApplicationDescription, Boolean>> { + return enrichWithBlockedState( + statsDatabase.getApIds(tracker.id).mapNotNull { + appListsRepository.getDisplayableApp(it) + }.sortedBy { it.label?.toString() }, + tracker + ) + } + + suspend fun enrichWithBlockedState(apps: List<ApplicationDescription>, tracker: Tracker): List<Pair<ApplicationDescription, Boolean>> { + return apps.map { it to !filterHostnameUseCase.isWhitelisted(it.uid, tracker.id) } + } + + suspend fun getCalls(tracker: Tracker): Pair<Int, Int> { + 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 <https://www.gnu.org/licenses/>. + */ +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<AppWithTrackersCount>, List<TrackerWithAppsCount>> { + 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<Tracker, Int>): List<TrackerWithAppsCount> { + return countByTracker.map { (tracker, count) -> + TrackerWithAppsCount(tracker = tracker, appsCount = count) + }.sortedByDescending { it.appsCount } + } + + private suspend fun buildAppList(countByApp: Map<ApplicationDescription, Int>): List<AppWithTrackersCount> { + return appListsRepository.apps().first().map { app: ApplicationDescription -> + AppWithTrackersCount(app = app, trackersCount = countByApp[app] ?: 0) + }.sortedByDescending { it.trackersCount } + } + + private suspend fun mapIdsToEntities(trackersAndAppsIds: List<Pair<String, String>>): List<Pair<Tracker, ApplicationDescription>> { + 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<Tracker, ApplicationDescription>>): + Pair<Map<ApplicationDescription, Int>, Map<Tracker, Int>> { + return trackersAndApps.fold( + mutableMapOf<ApplicationDescription, Int>() to mutableMapOf<Tracker, Int>() + ) { (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<Pair<Tracker, Boolean>> { - 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<Int, Int> { + suspend fun getCalls(app: ApplicationDescription): Pair<Int, Int> { return appListsRepository.mapReduceForHiddenApps( app = app, map = { @@ -211,67 +191,9 @@ class TrackersStatisticsUseCase( ) } - fun getAppsWithCounts(): Flow<List<AppWithCounts>> { - 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<Tracker> { return whitelistRepository.getWhiteListForApp(app).mapNotNull { trackersRepository.getTracker(it) } } - - private val mostLeakedAppsComparator: Comparator<AppWithCounts> = 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/common/AppsAdapter.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/AppsAdapter.kt index aee1890..f00dff8 100644 --- a/app/src/main/java/foundation/e/advancedprivacy/common/AppsAdapter.kt +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/AppsAdapter.kt @@ -15,42 +15,33 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -package foundation.e.advancedprivacy.common +package foundation.e.advancedprivacy.features.trackers 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 +import foundation.e.advancedprivacy.databinding.TrackersItemAppBinding class AppsAdapter( - private val itemsLayout: Int, - private val listener: (Int) -> Unit + private val viewModel: TrackersViewModel ) : RecyclerView.Adapter<AppsAdapter.ViewHolder>() { - 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) } + 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<AppWithCounts> = emptyList() + var dataSet: List<AppWithTrackersCount> = emptyList() set(value) { field = value notifyDataSetChanged() @@ -58,8 +49,8 @@ class AppsAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val view = LayoutInflater.from(parent.context) - .inflate(itemsLayout, parent, false) - return ViewHolder(view, listener) + .inflate(R.layout.trackers_item_app, parent, false) + return ViewHolder(view, viewModel) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { 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 <https://www.gnu.org/licenses/>. + */ +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<ListsTabPagerAdapter.ListsTabViewHolder>() { + private var apps: List<AppWithTrackersCount> = emptyList() + private var trackers: List<TrackerWithAppsCount> = emptyList() + + fun updateDataSet(apps: List<AppWithTrackersCount>?, trackers: List<TrackerWithAppsCount>?) { + 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<AppWithTrackersCount>) { + (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<TrackerWithAppsCount>) { + (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 <https://www.gnu.org/licenses/>. + */ +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 <https://www.gnu.org/licenses/>. + */ + +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<TrackersAdapter.ViewHolder>() { + + 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<TrackerWithAppsCount> = 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<AppWithCounts>? = null, + val apps: List<AppWithTrackersCount>? = null, + val trackers: List<TrackerWithAppsCount>? = 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<Pair<Tracker, Boolean>>? = null, + val trackersWithBlockedList: List<Pair<Tracker, Boolean>> = emptyList(), val leaked: Int = 0, val blocked: Int = 0, val isTrackersBlockingEnabled: Boolean = false, val isWhitelistEmpty: Boolean = true, - val showQuickPrivacyDisabledMessage: Boolean = false, ) { - fun getTrackersStatus(): List<Pair<Tracker, Boolean>>? { - return trackersWithWhiteList?.map { it.first to !it.second } - } + fun getTrackersCount() = 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<ToggleTrackersAdapter.ViewHolder>() { - - var isEnabled = true - class ViewHolder( - view: View, - private val onToggleSwitch: (Tracker, Boolean) -> Unit, - private val onClickTitle: (Tracker) -> Unit - ) : RecyclerView.ViewHolder(view) { - val title: TextView = view.findViewById(R.id.title) + private val binding: ApptrackersItemTrackerToggleBinding, + private val viewModel: AppTrackersViewModel, + ) : RecyclerView.ViewHolder(binding.root) { - val toggle: Switch = view.findViewById(R.id.toggle) + fun bind(item: Pair<Tracker, Boolean>) { + val label = item.first.label + with(binding.title) { + if (item.first.exodusId != null) { - fun bind(item: Pair<Tracker, Boolean>, isEnabled: Boolean) { - val text = item.first.label - if (item.first.exodusId != null) { - title.setTextColor(ContextCompat.getColor(title.context, R.color.accent)) - val spannable = SpannableString(text) - spannable.setSpan(UnderlineSpan(), 0, spannable.length, 0) - title.text = spannable - } else { - title.setTextColor(ContextCompat.getColor(title.context, R.color.primary_text)) - title.text = text + 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<Pair<Tracker, Boolean>> = emptyList() - fun updateDataSet(new: List<Pair<Tracker, Boolean>>, isEnabled: Boolean) { - this.isEnabled = isEnabled + fun updateDataSet(new: List<Pair<Tracker, Boolean>>) { 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 <https://www.gnu.org/licenses/>. + */ + +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<TrackerAppsAdapter.ViewHolder>() { + + class ViewHolder( + private val binding: ApptrackersItemTrackerToggleBinding, + private val viewModel: TrackerDetailsViewModel, + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: Pair<ApplicationDescription, Boolean>) { + val (app, isWhiteListed) = item + binding.title.text = app.label + binding.toggle.apply { + this.isChecked = isWhiteListed + setOnClickListener { + viewModel.onToggleUnblockApp(app, isChecked) + } + } + } + } + + private var dataSet: List<Pair<ApplicationDescription, Boolean>> = emptyList() + + fun updateDataSet(new: List<Pair<ApplicationDescription, Boolean>>) { + 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 <https://www.gnu.org/licenses/>. + */ + +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 <https://www.gnu.org/licenses/>. + */ + +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<Pair<ApplicationDescription, Boolean>> = 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 <https://www.gnu.org/licenses/>. + */ + +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<SingleEvent>() + 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() + } +} |