diff options
Diffstat (limited to 'app/src/main/java/foundation/e/advancedprivacy/features')
15 files changed, 1018 insertions, 281 deletions
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 <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.recyclerview.widget.RecyclerView +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.databinding.TrackersItemAppBinding + +class AppsAdapter( + private val viewModel: TrackersViewModel +) : + RecyclerView.Adapter<AppsAdapter.ViewHolder>() { + + 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<AppWithTrackersCount> = 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 <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() + } +} |