diff options
author | Guillaume Jacquart <guillaume.jacquart@hoodbrains.com> | 2023-05-02 21:25:17 +0200 |
---|---|---|
committer | Guillaume Jacquart <guillaume.jacquart@hoodbrains.com> | 2023-05-02 22:00:35 +0200 |
commit | a8874167f663885f2d3371801cf03681576ac817 (patch) | |
tree | 5be07b8768142efeade536d4135f2250c1ac9071 /app/src/main/java/foundation/e/advancedprivacy | |
parent | a0ee04ea9dbc0802c828afdf660eb37dc6fa350f (diff) |
1200: rename everything to AdvancedPrivacy
Diffstat (limited to 'app/src/main/java/foundation/e/advancedprivacy')
56 files changed, 6180 insertions, 0 deletions
diff --git a/app/src/main/java/foundation/e/advancedprivacy/AdvancedPrivacyApplication.kt b/app/src/main/java/foundation/e/advancedprivacy/AdvancedPrivacyApplication.kt new file mode 100644 index 0000000..8c39eb0 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/AdvancedPrivacyApplication.kt @@ -0,0 +1,36 @@ +/* + * 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 + +import android.app.Application +import com.mapbox.mapboxsdk.Mapbox +import foundation.e.lib.telemetry.Telemetry + +class AdvancedPrivacyApplication : Application() { + + // Initialize the dependency container. + val dependencyContainer: DependencyContainer by lazy { DependencyContainer(this) } + + override fun onCreate() { + super.onCreate() + Telemetry.init(BuildConfig.SENTRY_DSN, this, true) + Mapbox.getTelemetry()?.setUserTelemetryRequestState(false) + + dependencyContainer.initBackgroundSingletons() + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/DependencyContainer.kt b/app/src/main/java/foundation/e/advancedprivacy/DependencyContainer.kt new file mode 100644 index 0000000..91e2f44 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/DependencyContainer.kt @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2023 MURENA SAS + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy + +import android.app.Application +import android.content.Context +import android.os.Process +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import foundation.e.advancedprivacy.common.WarningDialog +import foundation.e.advancedprivacy.data.repositories.AppListsRepository +import foundation.e.advancedprivacy.data.repositories.LocalStateRepository +import foundation.e.advancedprivacy.data.repositories.TrackersRepository +import foundation.e.advancedprivacy.domain.usecases.AppListUseCase +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.TrackersStateUseCase +import foundation.e.advancedprivacy.domain.usecases.TrackersStatisticsUseCase +import foundation.e.advancedprivacy.dummy.CityDataSource +import foundation.e.advancedprivacy.features.dashboard.DashboardViewModel +import foundation.e.advancedprivacy.features.internetprivacy.InternetPrivacyViewModel +import foundation.e.advancedprivacy.features.location.FakeLocationViewModel +import foundation.e.advancedprivacy.features.trackers.TrackersViewModel +import foundation.e.advancedprivacy.features.trackers.apptrackers.AppTrackersFragment +import foundation.e.advancedprivacy.features.trackers.apptrackers.AppTrackersViewModel +import foundation.e.privacymodules.fakelocation.FakeLocationModule +import foundation.e.privacymodules.ipscrambler.IpScramblerModule +import foundation.e.privacymodules.ipscramblermodule.IIpScramblerModule +import foundation.e.privacymodules.permissions.PermissionsPrivacyModule +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import foundation.e.privacymodules.permissions.data.ProfileType +import foundation.e.privacymodules.trackers.api.BlockTrackersPrivacyModule +import foundation.e.privacymodules.trackers.api.TrackTrackersPrivacyModule +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope + +/** + * Simple container to hold application wide dependencies. + * + */ +@OptIn(DelicateCoroutinesApi::class) +class DependencyContainer(val app: Application) { + val context: Context by lazy { app.applicationContext } + + // Drivers + private val fakeLocationModule: FakeLocationModule by lazy { FakeLocationModule(app.applicationContext) } + private val permissionsModule by lazy { PermissionsPrivacyModule(app.applicationContext) } + private val ipScramblerModule: IIpScramblerModule by lazy { IpScramblerModule(app.applicationContext) } + + private val appDesc by lazy { + ApplicationDescription( + packageName = context.packageName, + uid = Process.myUid(), + label = context.resources.getString(R.string.app_name), + icon = null, + profileId = -1, + profileType = ProfileType.MAIN + ) + } + + private val blockTrackersPrivacyModule by lazy { BlockTrackersPrivacyModule.getInstance(context) } + private val trackTrackersPrivacyModule by lazy { TrackTrackersPrivacyModule.getInstance(context) } + + // Repositories + private val localStateRepository by lazy { LocalStateRepository(context) } + private val trackersRepository by lazy { TrackersRepository(context) } + private val appListsRepository by lazy { AppListsRepository(permissionsModule, context, GlobalScope) } + + // Usecases + val getQuickPrivacyStateUseCase by lazy { + GetQuickPrivacyStateUseCase(localStateRepository) + } + private val ipScramblingStateUseCase by lazy { + IpScramblingStateUseCase( + ipScramblerModule, permissionsModule, appDesc, localStateRepository, + appListsRepository, GlobalScope + ) + } + private val appListUseCase = AppListUseCase(appListsRepository) + + val trackersStatisticsUseCase by lazy { + TrackersStatisticsUseCase(trackTrackersPrivacyModule, blockTrackersPrivacyModule, appListsRepository, context.resources) + } + + val trackersStateUseCase by lazy { + TrackersStateUseCase(blockTrackersPrivacyModule, trackTrackersPrivacyModule, localStateRepository, trackersRepository, appListsRepository, GlobalScope) + } + + private val fakeLocationStateUseCase by lazy { + FakeLocationStateUseCase( + fakeLocationModule, permissionsModule, localStateRepository, CityDataSource, appDesc, context, GlobalScope + ) + } + + val showFeaturesWarningUseCase by lazy { + ShowFeaturesWarningUseCase(localStateRepository = localStateRepository) + } + + val viewModelsFactory by lazy { + ViewModelsFactory( + getQuickPrivacyStateUseCase = getQuickPrivacyStateUseCase, + trackersStatisticsUseCase = trackersStatisticsUseCase, + trackersStateUseCase = trackersStateUseCase, + fakeLocationStateUseCase = fakeLocationStateUseCase, + ipScramblerModule = ipScramblerModule, + ipScramblingStateUseCase = ipScramblingStateUseCase, + appListUseCase = appListUseCase + ) + } + + // Background + fun initBackgroundSingletons() { + trackersStateUseCase + ipScramblingStateUseCase + fakeLocationStateUseCase + + UpdateTrackersWorker.periodicUpdate(context) + + WarningDialog.startListening( + showFeaturesWarningUseCase, + GlobalScope, + context + ) + + Widget.startListening( + context, + getQuickPrivacyStateUseCase, + trackersStatisticsUseCase, + ) + + Notifications.startListening( + context, + getQuickPrivacyStateUseCase, + permissionsModule, + GlobalScope + ) + } +} + +@Suppress("LongParameterList") +class ViewModelsFactory( + private val getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase, + private val trackersStatisticsUseCase: TrackersStatisticsUseCase, + private val trackersStateUseCase: TrackersStateUseCase, + private val fakeLocationStateUseCase: FakeLocationStateUseCase, + private val ipScramblerModule: IIpScramblerModule, + private val ipScramblingStateUseCase: IpScramblingStateUseCase, + private val appListUseCase: AppListUseCase +) : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T { + return when (modelClass) { + AppTrackersViewModel::class.java -> { + val app = extras[DEFAULT_ARGS_KEY]?.getInt(AppTrackersFragment.PARAM_APP_UID)?.let { + appListUseCase.getApp(it) + } ?: appListUseCase.dummySystemApp + + AppTrackersViewModel( + app = app, + trackersStateUseCase = trackersStateUseCase, + trackersStatisticsUseCase = trackersStatisticsUseCase, + getQuickPrivacyStateUseCase = getQuickPrivacyStateUseCase + ) + } + + TrackersViewModel::class.java -> + TrackersViewModel( + trackersStatisticsUseCase = trackersStatisticsUseCase + ) + FakeLocationViewModel::class.java -> + FakeLocationViewModel( + getQuickPrivacyStateUseCase = getQuickPrivacyStateUseCase, + fakeLocationStateUseCase = fakeLocationStateUseCase + ) + InternetPrivacyViewModel::class.java -> + InternetPrivacyViewModel( + ipScramblerModule = ipScramblerModule, + getQuickPrivacyStateUseCase = getQuickPrivacyStateUseCase, + ipScramblingStateUseCase = ipScramblingStateUseCase, + appListUseCase = appListUseCase + ) + DashboardViewModel::class.java -> + DashboardViewModel( + getPrivacyStateUseCase = getQuickPrivacyStateUseCase, + trackersStatisticsUseCase = trackersStatisticsUseCase + ) + else -> throw IllegalArgumentException("Unknown class $modelClass") + } as T + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/Notifications.kt b/app/src/main/java/foundation/e/advancedprivacy/Notifications.kt new file mode 100644 index 0000000..68c4bd3 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/Notifications.kt @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2022 MURENA SAS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.annotation.StringRes +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode +import foundation.e.advancedprivacy.domain.entities.MainFeatures +import foundation.e.advancedprivacy.domain.usecases.GetQuickPrivacyStateUseCase +import foundation.e.advancedprivacy.main.MainActivity +import foundation.e.privacymodules.permissions.PermissionsPrivacyModule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +object Notifications { + const val CHANNEL_FIRST_BOOT = "first_boot_notification" + const val CHANNEL_FAKE_LOCATION_FLAG = "fake_location_flag" + const val CHANNEL_IPSCRAMBLING_FLAG = "ipscrambling_flag" + + const val NOTIFICATION_FIRST_BOOT = 1000 + const val NOTIFICATION_FAKE_LOCATION_FLAG = NOTIFICATION_FIRST_BOOT + 1 + const val NOTIFICATION_IPSCRAMBLING_FLAG = NOTIFICATION_FAKE_LOCATION_FLAG + 1 + + fun showFirstBootNotification(context: Context) { + createNotificationFirstBootChannel(context) + val notificationBuilder: NotificationCompat.Builder = notificationBuilder( + context, + NotificationContent( + channelId = CHANNEL_FIRST_BOOT, + icon = R.drawable.ic_notification_logo, + title = R.string.first_notification_title, + description = R.string.first_notification_summary, + destinationIntent = + context.packageManager.getLaunchIntentForPackage(context.packageName) + ) + ) + .setAutoCancel(true) + + NotificationManagerCompat.from(context).notify( + NOTIFICATION_FIRST_BOOT, notificationBuilder.build() + ) + } + + fun startListening( + appContext: Context, + getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase, + permissionsPrivacyModule: PermissionsPrivacyModule, + appScope: CoroutineScope + ) { + createNotificationFlagChannel( + context = appContext, + permissionsPrivacyModule = permissionsPrivacyModule, + channelId = CHANNEL_FAKE_LOCATION_FLAG, + channelName = R.string.notifications_fake_location_channel_name, + channelDescription = R.string.notifications_fake_location_channel_description + ) + + createNotificationFlagChannel( + context = appContext, + permissionsPrivacyModule = permissionsPrivacyModule, + channelId = CHANNEL_IPSCRAMBLING_FLAG, + channelName = R.string.notifications_ipscrambling_channel_name, + channelDescription = R.string.notifications_ipscrambling_channel_description + ) + + getQuickPrivacyStateUseCase.isLocationHidden.onEach { + if (it) { + showFlagNotification(appContext, MainFeatures.FAKE_LOCATION) + } else { + hideFlagNotification(appContext, MainFeatures.FAKE_LOCATION) + } + }.launchIn(appScope) + + getQuickPrivacyStateUseCase.ipScramblingMode.map { + it != InternetPrivacyMode.REAL_IP + }.distinctUntilChanged().onEach { + if (it) { + showFlagNotification(appContext, MainFeatures.IP_SCRAMBLING) + } else { + hideFlagNotification(appContext, MainFeatures.IP_SCRAMBLING) + } + }.launchIn(appScope) + } + + private fun createNotificationFirstBootChannel(context: Context) { + val channel = NotificationChannel( + CHANNEL_FIRST_BOOT, + context.getString(R.string.notifications_first_boot_channel_name), + NotificationManager.IMPORTANCE_HIGH + ) + NotificationManagerCompat.from(context).createNotificationChannel(channel) + } + + private fun createNotificationFlagChannel( + context: Context, + permissionsPrivacyModule: PermissionsPrivacyModule, + channelId: String, + @StringRes channelName: Int, + @StringRes channelDescription: Int, + ) { + val channel = NotificationChannel( + channelId, context.getString(channelName), NotificationManager.IMPORTANCE_LOW + ) + channel.description = context.getString(channelDescription) + permissionsPrivacyModule.setBlockable(channel) + NotificationManagerCompat.from(context).createNotificationChannel(channel) + } + + private fun showFlagNotification(context: Context, feature: MainFeatures) { + when (feature) { + MainFeatures.FAKE_LOCATION -> showFlagNotification( + context = context, + id = NOTIFICATION_FAKE_LOCATION_FLAG, + content = NotificationContent( + channelId = CHANNEL_FAKE_LOCATION_FLAG, + icon = R.drawable.ic_fmd_bad, + title = R.string.notifications_fake_location_title, + description = R.string.notifications_fake_location_content, + destinationIntent = MainActivity.createFakeLocationIntent(context), + ) + ) + MainFeatures.IP_SCRAMBLING -> showFlagNotification( + context = context, + id = NOTIFICATION_IPSCRAMBLING_FLAG, + content = NotificationContent( + channelId = CHANNEL_IPSCRAMBLING_FLAG, + icon = R.drawable.ic_language, + title = R.string.notifications_ipscrambling_title, + description = R.string.notifications_ipscrambling_content, + destinationIntent = MainActivity.createIpScramblingIntent(context), + ) + ) + else -> {} + } + } + + private fun showFlagNotification( + context: Context, + id: Int, + content: NotificationContent, + ) { + val builder = notificationBuilder(context, content) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + + NotificationManagerCompat.from(context).notify(id, builder.build()) + } + + private fun hideFlagNotification(context: Context, feature: MainFeatures) { + val id = when (feature) { + MainFeatures.FAKE_LOCATION -> NOTIFICATION_FAKE_LOCATION_FLAG + MainFeatures.IP_SCRAMBLING -> NOTIFICATION_IPSCRAMBLING_FLAG + else -> return + } + NotificationManagerCompat.from(context).cancel(id) + } + + private data class NotificationContent( + val channelId: String, + val icon: Int, + val title: Int, + val description: Int, + val destinationIntent: Intent? + ) + + private fun notificationBuilder( + context: Context, + content: NotificationContent + ): NotificationCompat.Builder { + val builder = NotificationCompat.Builder(context, content.channelId) + .setSmallIcon(content.icon) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setContentTitle(context.getString(content.title)) + .setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(content.description))) + + content.destinationIntent?.let { + it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + val pendingIntent: PendingIntent = PendingIntent.getActivity( + context, 0, it, PendingIntent.FLAG_IMMUTABLE + ) + builder.setContentIntent(pendingIntent) + } + + return builder + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/UpdateTrackersWorker.kt b/app/src/main/java/foundation/e/advancedprivacy/UpdateTrackersWorker.kt new file mode 100644 index 0000000..418f75b --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/UpdateTrackersWorker.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import java.util.concurrent.TimeUnit + +class UpdateTrackersWorker(appContext: Context, workerParams: WorkerParameters) : + CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + val trackersStateUseCase = (applicationContext as AdvancedPrivacyApplication) + .dependencyContainer.trackersStateUseCase + + trackersStateUseCase.updateTrackers() + return Result.success() + } + + companion object { + private val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + fun periodicUpdate(context: Context) { + val request = PeriodicWorkRequestBuilder<UpdateTrackersWorker>( + 7, TimeUnit.DAYS + ) + .setConstraints(constraints).build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + UpdateTrackersWorker::class.qualifiedName ?: "", + ExistingPeriodicWorkPolicy.KEEP, + request + ) + } + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/common/AppsAdapter.kt b/app/src/main/java/foundation/e/advancedprivacy/common/AppsAdapter.kt new file mode 100644 index 0000000..aee1890 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/common/AppsAdapter.kt @@ -0,0 +1,71 @@ +/* + * 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.common + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.domain.entities.AppWithCounts + +class AppsAdapter( + private val itemsLayout: Int, + private val listener: (Int) -> Unit +) : + RecyclerView.Adapter<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) } + } + } + + var dataSet: List<AppWithCounts> = emptyList() + set(value) { + field = value + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(itemsLayout, parent, false) + return ViewHolder(view, listener) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val app = dataSet[position] + holder.bind(app) + } + + override fun getItemCount(): Int = dataSet.size +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/common/BootCompletedReceiver.kt b/app/src/main/java/foundation/e/advancedprivacy/common/BootCompletedReceiver.kt new file mode 100644 index 0000000..d73f770 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/common/BootCompletedReceiver.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.common + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import foundation.e.advancedprivacy.Notifications +import foundation.e.advancedprivacy.data.repositories.LocalStateRepository + +class BootCompletedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + if (intent?.action == Intent.ACTION_BOOT_COMPLETED) { + val localStateRepository = LocalStateRepository(context) + if (localStateRepository.firstBoot) { + Notifications.showFirstBootNotification(context) + localStateRepository.firstBoot = false + } + } + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/common/Factory.kt b/app/src/main/java/foundation/e/advancedprivacy/common/Factory.kt new file mode 100644 index 0000000..3af0b37 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/common/Factory.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.common + +// Definition of a Factory interface with a function to create objects of a type +interface Factory<T> { + fun create(): T +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/common/GraphHolder.kt b/app/src/main/java/foundation/e/advancedprivacy/common/GraphHolder.kt new file mode 100644 index 0000000..ca4fcb6 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/common/GraphHolder.kt @@ -0,0 +1,333 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.common + +import android.content.Context +import android.graphics.Canvas +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.DynamicDrawableSpan +import android.text.style.ImageSpan +import android.view.View +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.text.toSpannable +import androidx.core.view.isVisible +import com.github.mikephil.charting.charts.BarChart +import com.github.mikephil.charting.components.AxisBase +import com.github.mikephil.charting.components.MarkerView +import com.github.mikephil.charting.components.XAxis +import com.github.mikephil.charting.components.YAxis +import com.github.mikephil.charting.components.YAxis.AxisDependency +import com.github.mikephil.charting.data.BarData +import com.github.mikephil.charting.data.BarDataSet +import com.github.mikephil.charting.data.BarEntry +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.formatter.ValueFormatter +import com.github.mikephil.charting.highlight.Highlight +import com.github.mikephil.charting.listener.OnChartValueSelectedListener +import com.github.mikephil.charting.renderer.XAxisRenderer +import com.github.mikephil.charting.utils.MPPointF +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.common.extensions.dpToPxF +import kotlin.math.floor + +class GraphHolder(val barChart: BarChart, val context: Context, val isMarkerAbove: Boolean = true) { + var data = emptyList<Pair<Int, Int>>() + set(value) { + field = value + refreshDataSet() + } + var labels = emptyList<String>() + + var graduations: List<String?>? = null + + private var isHighlighted = false + + init { + barChart.description = null + barChart.setTouchEnabled(true) + barChart.setScaleEnabled(false) + + barChart.setDrawGridBackground(false) + barChart.setDrawBorders(false) + barChart.axisLeft.isEnabled = false + barChart.axisRight.isEnabled = false + + barChart.legend.isEnabled = false + + if (isMarkerAbove) prepareXAxisDashboardDay() else prepareXAxisMarkersBelow() + + val periodMarker = PeriodMarkerView(context, isMarkerAbove) + periodMarker.chartView = barChart + barChart.marker = periodMarker + + barChart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener { + override fun onValueSelected(e: Entry?, h: Highlight?) { + h?.let { + val index = it.x.toInt() + if (index >= 0 && + index < labels.size && + index < this@GraphHolder.data.size + ) { + val period = labels[index] + val (blocked, leaked) = this@GraphHolder.data[index] + periodMarker.setLabel(period, blocked, leaked) + } + } + isHighlighted = true + } + + override fun onNothingSelected() { + isHighlighted = false + } + }) + } + + private fun prepareXAxisDashboardDay() { + barChart.extraTopOffset = 44f + + barChart.offsetTopAndBottom(0) + + barChart.setXAxisRenderer(object : XAxisRenderer(barChart.viewPortHandler, barChart.xAxis, barChart.getTransformer(AxisDependency.LEFT)) { + override fun renderAxisLine(c: Canvas) { + mAxisLinePaint.color = mXAxis.axisLineColor + mAxisLinePaint.strokeWidth = mXAxis.axisLineWidth + mAxisLinePaint.pathEffect = mXAxis.axisLineDashPathEffect + + // Top line + c.drawLine( + mViewPortHandler.contentLeft(), + mViewPortHandler.contentTop(), mViewPortHandler.contentRight(), + mViewPortHandler.contentTop(), mAxisLinePaint + ) + + // Bottom line + c.drawLine( + mViewPortHandler.contentLeft(), + mViewPortHandler.contentBottom() - 7.dpToPxF(context), + mViewPortHandler.contentRight(), + mViewPortHandler.contentBottom() - 7.dpToPxF(context), + mAxisLinePaint + ) + } + + override fun renderGridLines(c: Canvas) { + if (!mXAxis.isDrawGridLinesEnabled || !mXAxis.isEnabled) return + val clipRestoreCount = c.save() + c.clipRect(gridClippingRect) + if (mRenderGridLinesBuffer.size != mAxis.mEntryCount * 2) { + mRenderGridLinesBuffer = FloatArray(mXAxis.mEntryCount * 2) + } + val positions = mRenderGridLinesBuffer + run { + var i = 0 + while (i < positions.size) { + positions[i] = mXAxis.mEntries[i / 2] + positions[i + 1] = mXAxis.mEntries[i / 2] + i += 2 + } + } + + mTrans.pointValuesToPixel(positions) + setupGridPaint() + val gridLinePath = mRenderGridLinesPath + gridLinePath.reset() + var i = 0 + while (i < positions.size) { + val bottomY = if (graduations?.getOrNull(i / 2) != null) 0 else 3 + val x = positions[i] + gridLinePath.moveTo(x, mViewPortHandler.contentBottom() - 7.dpToPxF(context)) + gridLinePath.lineTo(x, mViewPortHandler.contentBottom() - bottomY.dpToPxF(context)) + + c.drawPath(gridLinePath, mGridPaint) + + gridLinePath.reset() + + i += 2 + } + c.restoreToCount(clipRestoreCount) + } + }) + + barChart.setDrawValueAboveBar(false) + barChart.xAxis.apply { + isEnabled = true + position = XAxis.XAxisPosition.BOTTOM + + setDrawGridLines(true) + setDrawLabels(true) + setCenterAxisLabels(false) + setLabelCount(25, true) + textColor = context.getColor(R.color.primary_text) + valueFormatter = object : ValueFormatter() { + override fun getAxisLabel(value: Float, axis: AxisBase?): String { + return graduations?.getOrNull(floor(value).toInt() + 1) ?: "" + } + } + } + } + + private fun prepareXAxisMarkersBelow() { + barChart.extraBottomOffset = 44f + + barChart.offsetTopAndBottom(0) + barChart.setDrawValueAboveBar(false) + + barChart.xAxis.apply { + isEnabled = true + position = XAxis.XAxisPosition.BOTH_SIDED + setDrawGridLines(false) + setDrawLabels(false) + } + } + + fun highlightIndex(index: Int) { + if (index >= 0 && index < data.size) { + val xPx = barChart.getTransformer(YAxis.AxisDependency.LEFT) + .getPixelForValues(index.toFloat(), 0f) + .x + val highlight = Highlight( + index.toFloat(), 0f, + xPx.toFloat(), 0f, + 0, YAxis.AxisDependency.LEFT + ) + + barChart.highlightValue(highlight, true) + } + } + + private fun refreshDataSet() { + val trackersDataSet = BarDataSet( + data.mapIndexed { index, value -> + BarEntry( + index.toFloat(), + floatArrayOf(value.first.toFloat(), value.second.toFloat()) + ) + }, + "" + ).apply { + + val blockedColor = ContextCompat.getColor(context, R.color.accent) + val leakedColor = ContextCompat.getColor(context, R.color.red_off) + + colors = listOf( + blockedColor, + leakedColor + ) + + setDrawValues(false) + } + + barChart.data = BarData(trackersDataSet) + barChart.invalidate() + } +} + +class PeriodMarkerView(context: Context, private val isMarkerAbove: Boolean = true) : MarkerView(context, R.layout.chart_tooltip) { + enum class ArrowPosition { LEFT, CENTER, RIGHT } + + private val arrowMargins = 10.dpToPxF(context) + private val mOffset2 = MPPointF(0f, 0f) + + private fun getArrowPosition(posX: Float): ArrowPosition { + val halfWidth = width / 2 + + return chartView?.let { chart -> + if (posX < halfWidth) { + ArrowPosition.LEFT + } else if (chart.width - posX < halfWidth) { + ArrowPosition.RIGHT + } else { + ArrowPosition.CENTER + } + } ?: ArrowPosition.CENTER + } + + private fun showArrow(position: ArrowPosition?) { + val ids = listOf( + R.id.arrow_top_left, R.id.arrow_top_center, R.id.arrow_top_right, + R.id.arrow_bottom_left, R.id.arrow_bottom_center, R.id.arrow_bottom_right + ) + + val toShow = if (isMarkerAbove) when (position) { + ArrowPosition.LEFT -> R.id.arrow_bottom_left + ArrowPosition.CENTER -> R.id.arrow_bottom_center + ArrowPosition.RIGHT -> R.id.arrow_bottom_right + else -> null + } else when (position) { + ArrowPosition.LEFT -> R.id.arrow_top_left + ArrowPosition.CENTER -> R.id.arrow_top_center + ArrowPosition.RIGHT -> R.id.arrow_top_right + else -> null + } + + ids.forEach { id -> + val showIt = id == toShow + findViewById<View>(id)?.let { + if (it.isVisible != showIt) { + it.isVisible = showIt + } + } + } + } + + fun setLabel(period: String, blocked: Int, leaked: Int) { + val span = SpannableStringBuilder(period) + span.append(": $blocked ") + span.setSpan( + ImageSpan(context, R.drawable.ic_legend_blocked, DynamicDrawableSpan.ALIGN_BASELINE), + span.length - 1, + span.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + span.append(" $leaked ") + span.setSpan( + ImageSpan(context, R.drawable.ic_legend_leaked, DynamicDrawableSpan.ALIGN_BASELINE), + span.length - 1, + span.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + findViewById<TextView>(R.id.label).text = span.toSpannable() + } + + override fun refreshContent(e: Entry?, highlight: Highlight?) { + highlight?.let { + showArrow(getArrowPosition(highlight.xPx)) + } + super.refreshContent(e, highlight) + } + + override fun getOffsetForDrawingAtPoint(posX: Float, posY: Float): MPPointF { + val x = when (getArrowPosition(posX)) { + ArrowPosition.LEFT -> -arrowMargins + ArrowPosition.RIGHT -> -width + arrowMargins + ArrowPosition.CENTER -> -width.toFloat() / 2 + } + + mOffset2.x = x + mOffset2.y = if (isMarkerAbove) -posY + else -posY + (chartView?.height?.toFloat() ?: 0f) - height + + return mOffset2 + } + + override fun draw(canvas: Canvas?, posX: Float, posY: Float) { + super.draw(canvas, posX, posY) + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/common/NavToolbarFragment.kt b/app/src/main/java/foundation/e/advancedprivacy/common/NavToolbarFragment.kt new file mode 100644 index 0000000..1417977 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/common/NavToolbarFragment.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.common + +import androidx.annotation.LayoutRes +import com.google.android.material.appbar.MaterialToolbar + +abstract class NavToolbarFragment(@LayoutRes contentLayoutId: Int) : ToolbarFragment(contentLayoutId) { + + override fun setupToolbar(toolbar: MaterialToolbar) { + super.setupToolbar(toolbar) + toolbar.apply { + setNavigationOnClickListener { + requireActivity().onBackPressed() + } + } + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/common/RightRadioButton.kt b/app/src/main/java/foundation/e/advancedprivacy/common/RightRadioButton.kt new file mode 100644 index 0000000..c10d755 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/common/RightRadioButton.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.common + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.widget.RadioButton + +/** + * A custom [RadioButton] which displays the radio drawable on the right side. + */ +@SuppressLint("AppCompatCustomView") +class RightRadioButton : RadioButton { + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) + + // Returns layout direction as right-to-left to draw the compound button on right side. + override fun getLayoutDirection(): Int { + return LAYOUT_DIRECTION_RTL + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/common/TextViewHelpers.kt b/app/src/main/java/foundation/e/advancedprivacy/common/TextViewHelpers.kt new file mode 100644 index 0000000..f87834a --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/common/TextViewHelpers.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.common + +import android.content.Context +import android.content.res.ColorStateList +import android.text.Spannable +import android.text.SpannableString +import android.text.style.DynamicDrawableSpan +import android.text.style.ImageSpan +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.appcompat.content.res.AppCompatResources +import androidx.appcompat.widget.TooltipCompat +import foundation.e.advancedprivacy.R + +fun setToolTipForAsterisk( + textView: TextView, + @StringRes textId: Int, + @StringRes tooltipTextId: Int +) { + textView.text = asteriskAsInfoIconSpannable(textView.context, textId, textView.textColors) + TooltipCompat.setTooltipText(textView, textView.context.getString(tooltipTextId)) + + textView.setOnClickListener { it.performLongClick() } +} + +private fun asteriskAsInfoIconSpannable( + context: Context, + @StringRes textId: Int, + tint: ColorStateList +): Spannable { + val spannable = SpannableString(context.getString(textId)) + val index = spannable.lastIndexOf("*") + if (index != -1) { + AppCompatResources.getDrawable(context, R.drawable.ic_info_16dp)?.let { + it.setTintList(tint) + it.setBounds(0, 0, it.intrinsicWidth, it.intrinsicHeight) + spannable.setSpan( + ImageSpan(it, DynamicDrawableSpan.ALIGN_CENTER), + index, + index + 1, + Spannable.SPAN_INCLUSIVE_INCLUSIVE + ) + } + } + return spannable +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/common/ThrottleFlow.kt b/app/src/main/java/foundation/e/advancedprivacy/common/ThrottleFlow.kt new file mode 100644 index 0000000..e9ec060 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/common/ThrottleFlow.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.common + +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlin.time.Duration + +@FlowPreview +fun <T> Flow<T>.throttleFirst(windowDuration: Duration): Flow<T> = flow { + var lastEmissionTime = 0L + collect { upstream -> + val currentTime = System.currentTimeMillis() + val mayEmit = currentTime - lastEmissionTime > windowDuration.inWholeMilliseconds + if (mayEmit) { + lastEmissionTime = currentTime + emit(upstream) + } + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/common/ToggleAppsAdapter.kt b/app/src/main/java/foundation/e/advancedprivacy/common/ToggleAppsAdapter.kt new file mode 100644 index 0000000..d8ee8ea --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/common/ToggleAppsAdapter.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.common + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CheckBox +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import foundation.e.advancedprivacy.R +import foundation.e.privacymodules.permissions.data.ApplicationDescription + +class ToggleAppsAdapter( + private val itemsLayout: Int, + private val listener: (String) -> Unit +) : + RecyclerView.Adapter<ToggleAppsAdapter.ViewHolder>() { + + class ViewHolder(view: View, private val listener: (String) -> Unit) : RecyclerView.ViewHolder(view) { + val appName: TextView = view.findViewById(R.id.title) + + val togglePermission: CheckBox = view.findViewById(R.id.toggle) + + fun bind(item: Pair<ApplicationDescription, Boolean>, isEnabled: Boolean) { + appName.text = item.first.label + togglePermission.isChecked = item.second + togglePermission.isEnabled = isEnabled + + itemView.findViewById<ImageView>(R.id.icon).setImageDrawable(item.first.icon) + togglePermission.setOnClickListener { listener(item.first.packageName) } + } + } + + var dataSet: List<Pair<ApplicationDescription, Boolean>> = emptyList() + set(value) { + field = value + notifyDataSetChanged() + } + + var isEnabled: Boolean = true + + fun setData(list: List<Pair<ApplicationDescription, Boolean>>, isEnabled: Boolean = true) { + this.isEnabled = isEnabled + dataSet = list + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(itemsLayout, parent, false) + return ViewHolder(view, listener) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val permission = dataSet[position] + holder.bind(permission, isEnabled) + } + + override fun getItemCount(): Int = dataSet.size +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/common/ToolbarFragment.kt b/app/src/main/java/foundation/e/advancedprivacy/common/ToolbarFragment.kt new file mode 100644 index 0000000..fb3ea14 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/common/ToolbarFragment.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.common + +import android.os.Bundle +import android.view.View +import androidx.annotation.LayoutRes +import androidx.fragment.app.Fragment +import com.google.android.material.appbar.MaterialToolbar +import foundation.e.advancedprivacy.R + +abstract class ToolbarFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayoutId) { + + /** + * @return title to be used in toolbar + */ + abstract fun getTitle(): String + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupToolbar(view.findViewById(R.id.toolbar)) + } + + open fun setupToolbar(toolbar: MaterialToolbar) { + toolbar.title = getTitle() + } + + fun getToolbar(): MaterialToolbar? = view?.findViewById(R.id.toolbar) +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/common/WarningDialog.kt b/app/src/main/java/foundation/e/advancedprivacy/common/WarningDialog.kt new file mode 100644 index 0000000..98deeb1 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/common/WarningDialog.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.common + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.CheckBox +import androidx.appcompat.app.AlertDialog +import foundation.e.advancedprivacy.AdvancedPrivacyApplication +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.domain.entities.MainFeatures +import foundation.e.advancedprivacy.domain.entities.MainFeatures.FAKE_LOCATION +import foundation.e.advancedprivacy.domain.entities.MainFeatures.IP_SCRAMBLING +import foundation.e.advancedprivacy.domain.entities.MainFeatures.TRACKERS_CONTROL +import foundation.e.advancedprivacy.domain.usecases.ShowFeaturesWarningUseCase +import foundation.e.advancedprivacy.main.MainActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map + +class WarningDialog : Activity() { + companion object { + private const val PARAM_FEATURE = "feature" + + fun startListening( + showFeaturesWarningUseCase: ShowFeaturesWarningUseCase, + appScope: CoroutineScope, + appContext: Context + ) { + showFeaturesWarningUseCase.showWarning().map { feature -> + appContext.startActivity( + createIntent(context = appContext, feature = feature) + ) + }.launchIn(appScope) + } + + private fun createIntent( + context: Context, + feature: MainFeatures, + ): Intent { + val intent = Intent(context, WarningDialog::class.java) + intent.putExtra(PARAM_FEATURE, feature.name) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + return intent + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + getWindow().setBackgroundDrawable(ColorDrawable(0)) + + val feature = try { + MainFeatures.valueOf(intent.getStringExtra(PARAM_FEATURE) ?: "") + } catch (e: Exception) { + Log.e("WarningDialog", "Missing mandatory activity parameter", e) + finish() + return + } + + showWarningDialog(feature) + } + + private fun showWarningDialog(feature: MainFeatures) { + val builder = AlertDialog.Builder(this) + builder.setOnDismissListener { finish() } + + val content: View = layoutInflater.inflate(R.layout.alertdialog_do_not_show_again, null) + val checkbox = content.findViewById<CheckBox>(R.id.checkbox) + builder.setView(content) + + builder.setMessage( + when (feature) { + TRACKERS_CONTROL -> R.string.warningdialog_trackers_message + FAKE_LOCATION -> R.string.warningdialog_location_message + IP_SCRAMBLING -> R.string.warningdialog_ipscrambling_message + } + ) + + builder.setTitle( + when (feature) { + TRACKERS_CONTROL -> R.string.warningdialog_trackers_title + FAKE_LOCATION -> R.string.warningdialog_location_title + IP_SCRAMBLING -> R.string.warningdialog_ipscrambling_title + } + ) + + builder.setPositiveButton( + when (feature) { + IP_SCRAMBLING -> R.string.warningdialog_ipscrambling_cta + else -> R.string.ok + } + ) { _, _ -> + if (checkbox.isChecked()) { + (application as AdvancedPrivacyApplication) + .dependencyContainer.showFeaturesWarningUseCase + .doNotShowAgain(feature) + } + finish() + } + + if (feature == TRACKERS_CONTROL) { + builder.setNeutralButton(R.string.warningdialog_trackers_secondary_cta) { _, _ -> + startActivity(MainActivity.createTrackersIntent(this)) + finish() + } + } + + builder.show() + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/common/extensions/AnyExtension.kt b/app/src/main/java/foundation/e/advancedprivacy/common/extensions/AnyExtension.kt new file mode 100644 index 0000000..652aefd --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/common/extensions/AnyExtension.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.common.extensions + +import android.content.Context + +fun Int.dpToPxF(context: Context): Float = this.toFloat() * context.resources.displayMetrics.density diff --git a/app/src/main/java/foundation/e/advancedprivacy/data/repositories/AppListsRepository.kt b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/AppListsRepository.kt new file mode 100644 index 0000000..0b951a8 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/AppListsRepository.kt @@ -0,0 +1,281 @@ +/* + * Copyright (C) 2022 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.data.repositories + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import foundation.e.advancedprivacy.R +import foundation.e.privacymodules.permissions.PermissionsPrivacyModule +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import foundation.e.privacymodules.permissions.data.ProfileType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +class AppListsRepository( + private val permissionsModule: PermissionsPrivacyModule, + private val context: Context, + private val coroutineScope: CoroutineScope +) { + companion object { + private const val PNAME_SETTINGS = "com.android.settings" + private const val PNAME_PWAPLAYER = "foundation.e.pwaplayer" + private const val PNAME_INTENT_VERIFICATION = "com.android.statementservice" + private const val PNAME_MICROG_SERVICES_CORE = "com.google.android.gms" + + val compatibiltyPNames = setOf( + PNAME_PWAPLAYER, PNAME_INTENT_VERIFICATION, PNAME_MICROG_SERVICES_CORE + ) + } + + val dummySystemApp = ApplicationDescription( + packageName = "foundation.e.dummysystemapp", + uid = -1, + label = context.getString(R.string.dummy_system_app_label), + icon = context.getDrawable(R.drawable.ic_e_app_logo), + profileId = -1, + profileType = ProfileType.MAIN + ) + + val dummyCompatibilityApp = ApplicationDescription( + packageName = "foundation.e.dummyappscompatibilityapp", + uid = -2, + label = context.getString(R.string.dummy_apps_compatibility_app_label), + icon = context.getDrawable(R.drawable.ic_apps_compatibility_components), + profileId = -1, + profileType = ProfileType.MAIN + ) + + private suspend fun fetchAppDescriptions(fetchMissingIcons: Boolean = false) { + val launcherPackageNames = context.packageManager.queryIntentActivities( + Intent(Intent.ACTION_MAIN, null).apply { addCategory(Intent.CATEGORY_LAUNCHER) }, + 0 + ).mapNotNull { it.activityInfo?.packageName } + + val visibleAppsFilter = { packageInfo: PackageInfo -> + hasInternetPermission(packageInfo) && + isStandardApp(packageInfo.applicationInfo, launcherPackageNames) + } + + val hiddenAppsFilter = { packageInfo: PackageInfo -> + hasInternetPermission(packageInfo) && + isHiddenSystemApp(packageInfo.applicationInfo, launcherPackageNames) + } + + val compatibilityAppsFilter = { packageInfo: PackageInfo -> + packageInfo.packageName in compatibiltyPNames + } + + val visibleApps = recycleIcons( + newApps = permissionsModule.getApplications(visibleAppsFilter), + fetchMissingIcons = fetchMissingIcons + ) + val hiddenApps = permissionsModule.getApplications(hiddenAppsFilter) + val compatibilityApps = permissionsModule.getApplications(compatibilityAppsFilter) + + updateMaps(visibleApps + hiddenApps + compatibilityApps) + + allProfilesAppDescriptions.emit( + Triple( + visibleApps + dummySystemApp + dummyCompatibilityApp, + hiddenApps, + compatibilityApps + ) + ) + } + + private fun recycleIcons( + newApps: List<ApplicationDescription>, + fetchMissingIcons: Boolean + ): List<ApplicationDescription> { + val oldVisibleApps = allProfilesAppDescriptions.value.first + return newApps.map { app -> + app.copy( + icon = oldVisibleApps.find { app.apId == it.apId }?.icon + ?: if (fetchMissingIcons) permissionsModule.getApplicationIcon(app) else null + ) + } + } + + private fun updateMaps(apps: List<ApplicationDescription>) { + val byUid = mutableMapOf<Int, ApplicationDescription>() + val byApId = mutableMapOf<String, ApplicationDescription>() + apps.forEach { app -> + byUid[app.uid]?.run { packageName > app.packageName } == true + if (byUid[app.uid].let { it == null || it.packageName > app.packageName }) { + byUid[app.uid] = app + } + + byApId[app.apId] = app + } + appsByUid = byUid + appsByAPId = byApId + } + + private var lastFetchApps = 0 + private var refreshAppJob: Job? = null + private fun refreshAppDescriptions(fetchMissingIcons: Boolean = true, force: Boolean = false): Job? { + if (refreshAppJob == null) { + refreshAppJob = coroutineScope.launch(Dispatchers.IO) { + if (force || context.packageManager.getChangedPackages(lastFetchApps) != null) { + fetchAppDescriptions(fetchMissingIcons = fetchMissingIcons) + if (fetchMissingIcons) { + lastFetchApps = context.packageManager.getChangedPackages(lastFetchApps) + ?.sequenceNumber ?: lastFetchApps + } + + refreshAppJob = null + } + } + } + + return refreshAppJob + } + + fun mainProfileApps(): Flow<List<ApplicationDescription>> { + refreshAppDescriptions() + return allProfilesAppDescriptions.map { + it.first.filter { app -> app.profileType == ProfileType.MAIN } + .sortedBy { app -> app.label.toString().lowercase() } + } + } + + fun getMainProfileHiddenSystemApps(): List<ApplicationDescription> { + return allProfilesAppDescriptions.value.second.filter { it.profileType == ProfileType.MAIN } + } + + fun apps(): Flow<List<ApplicationDescription>> { + refreshAppDescriptions() + return allProfilesAppDescriptions.map { + it.first.sortedBy { app -> app.label.toString().lowercase() } + } + } + + fun allApps(): Flow<List<ApplicationDescription>> { + return allProfilesAppDescriptions.map { + it.first + it.second + it.third + } + } + + private fun getHiddenSystemApps(): List<ApplicationDescription> { + return allProfilesAppDescriptions.value.second + } + + private fun getCompatibilityApps(): List<ApplicationDescription> { + return allProfilesAppDescriptions.value.third + } + + fun anyForHiddenApps(app: ApplicationDescription, test: (ApplicationDescription) -> Boolean): Boolean { + return if (app == dummySystemApp) { + getHiddenSystemApps().any { test(it) } + } else if (app == dummyCompatibilityApp) { + getCompatibilityApps().any { test(it) } + } else test(app) + } + + fun applyForHiddenApps(app: ApplicationDescription, action: (ApplicationDescription) -> Unit) { + mapReduceForHiddenApps(app = app, map = action, reduce = {}) + } + + fun <T, R> mapReduceForHiddenApps( + app: ApplicationDescription, + map: (ApplicationDescription) -> T, + reduce: (List<T>) -> R + ): R { + return if (app == dummySystemApp) { + reduce(getHiddenSystemApps().map(map)) + } else if (app == dummyCompatibilityApp) { + reduce(getCompatibilityApps().map(map)) + } else reduce(listOf(map(app))) + } + + private var appsByUid = mapOf<Int, ApplicationDescription>() + private var appsByAPId = mapOf<String, ApplicationDescription>() + + fun getApp(appUid: Int): ApplicationDescription? { + return appsByUid[appUid] ?: run { + runBlocking { refreshAppDescriptions(fetchMissingIcons = false, force = true)?.join() } + appsByUid[appUid] + } + } + + fun getApp(apId: String): ApplicationDescription? { + if (apId.isBlank()) return null + + return appsByAPId[apId] ?: run { + runBlocking { refreshAppDescriptions(fetchMissingIcons = false, force = true)?.join() } + appsByAPId[apId] + } + } + + private val allProfilesAppDescriptions = MutableStateFlow( + Triple( + emptyList<ApplicationDescription>(), + emptyList<ApplicationDescription>(), + emptyList<ApplicationDescription>() + ) + ) + + private fun hasInternetPermission(packageInfo: PackageInfo): Boolean { + return packageInfo.requestedPermissions?.contains(Manifest.permission.INTERNET) == true + } + + @Suppress("ReturnCount") + private fun isNotHiddenSystemApp(app: ApplicationInfo, launcherApps: List<String>): Boolean { + if (app.packageName == PNAME_SETTINGS) { + return false + } else if (app.packageName == PNAME_PWAPLAYER) { + return true + } else if (app.hasFlag(ApplicationInfo.FLAG_UPDATED_SYSTEM_APP)) { + return true + } else if (!app.hasFlag(ApplicationInfo.FLAG_SYSTEM)) { + return true + } else if (launcherApps.contains(app.packageName)) { + return true + } + return false + } + + private fun isStandardApp(app: ApplicationInfo, launcherApps: List<String>): Boolean { + return when { + app.packageName == PNAME_SETTINGS -> false + app.packageName in compatibiltyPNames -> false + app.hasFlag(ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) -> true + !app.hasFlag(ApplicationInfo.FLAG_SYSTEM) -> true + launcherApps.contains(app.packageName) -> true + else -> false + } + } + + private fun isHiddenSystemApp(app: ApplicationInfo, launcherApps: List<String>): Boolean { + return when { + app.packageName in compatibiltyPNames -> false + else -> !isNotHiddenSystemApp(app, launcherApps) + } + } + + private fun ApplicationInfo.hasFlag(flag: Int) = (flags and flag) == 1 +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/data/repositories/CityDataSource.kt b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/CityDataSource.kt new file mode 100644 index 0000000..06fb9ac --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/CityDataSource.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.dummy + +object CityDataSource { + private val BARCELONA = Pair(41.3851f, 2.1734f) + private val BUDAPEST = Pair(47.4979f, 19.0402f) + private val ABU_DHABI = Pair(24.4539f, 54.3773f) + private val HYDERABAD = Pair(17.3850f, 78.4867f) + private val QUEZON_CITY = Pair(14.6760f, 121.0437f) + private val PARIS = Pair(48.8566f, 2.3522f) + private val LONDON = Pair(51.5074f, 0.1278f) + private val SHANGHAI = Pair(31.2304f, 121.4737f) + private val MADRID = Pair(40.4168f, -3.7038f) + private val LAHORE = Pair(31.5204f, 74.3587f) + private val CHICAGO = Pair(41.8781f, -87.6298f) + + val citiesLocationsList = listOf( + BARCELONA, + BUDAPEST, + ABU_DHABI, + HYDERABAD, + QUEZON_CITY, + PARIS, + LONDON, + SHANGHAI, + MADRID, + LAHORE, + CHICAGO + ) +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/data/repositories/LocalStateRepository.kt b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/LocalStateRepository.kt new file mode 100644 index 0000000..3f73c78 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/LocalStateRepository.kt @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.data.repositories + +import android.content.Context +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode +import foundation.e.advancedprivacy.domain.entities.LocationMode +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class LocalStateRepository(context: Context) { + companion object { + private const val SHARED_PREFS_FILE = "localState" + private const val KEY_BLOCK_TRACKERS = "blockTrackers" + private const val KEY_IP_SCRAMBLING = "ipScrambling" + private const val KEY_FAKE_LOCATION = "fakeLocation" + private const val KEY_FAKE_LATITUDE = "fakeLatitude" + private const val KEY_FAKE_LONGITUDE = "fakeLongitude" + private const val KEY_FIRST_BOOT = "firstBoot" + private const val KEY_HIDE_WARNING_TRACKERS = "hide_warning_trackers" + private const val KEY_HIDE_WARNING_LOCATION = "hide_warning_location" + private const val KEY_HIDE_WARNING_IPSCRAMBLING = "hide_warning_ipscrambling" + } + + private val sharedPref = context.getSharedPreferences(SHARED_PREFS_FILE, Context.MODE_PRIVATE) + + private val _blockTrackers = MutableStateFlow(sharedPref.getBoolean(KEY_BLOCK_TRACKERS, true)) + val blockTrackers = _blockTrackers.asStateFlow() + + fun setBlockTrackers(enabled: Boolean) { + set(KEY_BLOCK_TRACKERS, enabled) + _blockTrackers.update { enabled } + } + + val areAllTrackersBlocked: MutableStateFlow<Boolean> = MutableStateFlow(false) + + private val _fakeLocationEnabled = MutableStateFlow(sharedPref.getBoolean(KEY_FAKE_LOCATION, false)) + + val fakeLocationEnabled = _fakeLocationEnabled.asStateFlow() + + fun setFakeLocationEnabled(enabled: Boolean) { + set(KEY_FAKE_LOCATION, enabled) + _fakeLocationEnabled.update { enabled } + } + + var fakeLocation: Pair<Float, Float> + get() = Pair( + // Initial default value is Quezon City + sharedPref.getFloat(KEY_FAKE_LATITUDE, 14.6760f), + sharedPref.getFloat(KEY_FAKE_LONGITUDE, 121.0437f) + ) + + set(value) { + sharedPref.edit() + .putFloat(KEY_FAKE_LATITUDE, value.first) + .putFloat(KEY_FAKE_LONGITUDE, value.second) + .apply() + } + + val locationMode: MutableStateFlow<LocationMode> = MutableStateFlow(LocationMode.REAL_LOCATION) + + private val _ipScramblingSetting = MutableStateFlow(sharedPref.getBoolean(KEY_IP_SCRAMBLING, false)) + val ipScramblingSetting = _ipScramblingSetting.asStateFlow() + + fun setIpScramblingSetting(enabled: Boolean) { + set(KEY_IP_SCRAMBLING, enabled) + _ipScramblingSetting.update { enabled } + } + + val internetPrivacyMode: MutableStateFlow<InternetPrivacyMode> = MutableStateFlow(InternetPrivacyMode.REAL_IP) + + private val _otherVpnRunning = MutableSharedFlow<ApplicationDescription>() + suspend fun emitOtherVpnRunning(appDesc: ApplicationDescription) { + _otherVpnRunning.emit(appDesc) + } + val otherVpnRunning: SharedFlow<ApplicationDescription> = _otherVpnRunning + + var firstBoot: Boolean + get() = sharedPref.getBoolean(KEY_FIRST_BOOT, true) + set(value) = set(KEY_FIRST_BOOT, value) + + var hideWarningTrackers: Boolean + get() = sharedPref.getBoolean(KEY_HIDE_WARNING_TRACKERS, false) + set(value) = set(KEY_HIDE_WARNING_TRACKERS, value) + + var hideWarningLocation: Boolean + get() = sharedPref.getBoolean(KEY_HIDE_WARNING_LOCATION, false) + set(value) = set(KEY_HIDE_WARNING_LOCATION, value) + + var hideWarningIpScrambling: Boolean + get() = sharedPref.getBoolean(KEY_HIDE_WARNING_IPSCRAMBLING, false) + set(value) = set(KEY_HIDE_WARNING_IPSCRAMBLING, value) + + private fun set(key: String, value: Boolean) { + sharedPref.edit().putBoolean(key, value).apply() + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/data/repositories/TrackersRepository.kt b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/TrackersRepository.kt new file mode 100644 index 0000000..82915df --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/data/repositories/TrackersRepository.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.data.repositories + +import android.content.Context +import android.util.Log +import com.google.gson.Gson +import foundation.e.privacymodules.trackers.api.Tracker +import retrofit2.Retrofit +import retrofit2.converter.scalars.ScalarsConverterFactory +import retrofit2.http.GET +import java.io.File +import java.io.FileInputStream +import java.io.FileWriter +import java.io.IOException +import java.io.InputStreamReader +import java.io.PrintWriter + +class TrackersRepository(private val context: Context) { + + private val eTrackerFileName = "e_trackers.json" + private val eTrackerFile = File(context.filesDir.absolutePath, eTrackerFileName) + + var trackers: List<Tracker> = emptyList() + private set + + init { + initTrackersFile() + } + + suspend fun update() { + val api = ETrackersApi.build() + saveData(eTrackerFile, api.trackers()) + initTrackersFile() + } + + private fun initTrackersFile() { + try { + var inputStream = context.assets.open(eTrackerFileName) + if (eTrackerFile.exists()) { + inputStream = FileInputStream(eTrackerFile) + } + val reader = InputStreamReader(inputStream, "UTF-8") + val trackerResponse = + Gson().fromJson(reader, ETrackersApi.ETrackersResponse::class.java) + + trackers = mapper(trackerResponse) + + reader.close() + inputStream.close() + } catch (e: Exception) { + Log.e("TrackersRepository", "While parsing trackers in assets", e) + } + } + + private fun mapper(response: ETrackersApi.ETrackersResponse): List<Tracker> { + return response.trackers.mapNotNull { + try { + it.toTracker() + } catch (e: Exception) { + null + } + } + } + + private fun ETrackersApi.ETrackersResponse.ETracker.toTracker(): Tracker { + return Tracker( + id = id!!, + hostnames = hostnames!!.toSet(), + label = name!!, + exodusId = exodusId + ) + } + + private fun saveData(file: File, data: String): Boolean { + try { + val fos = FileWriter(file, false) + val ps = PrintWriter(fos) + ps.apply { + print(data) + flush() + close() + } + return true + } catch (e: IOException) { + e.printStackTrace() + } + return false + } +} + +interface ETrackersApi { + companion object { + fun build(): ETrackersApi { + val retrofit = Retrofit.Builder() + .baseUrl("https://gitlab.e.foundation/e/os/tracker-list/-/raw/main/") + .addConverterFactory(ScalarsConverterFactory.create()) + .build() + return retrofit.create(ETrackersApi::class.java) + } + } + + @GET("list/e_trackers.json") + suspend fun trackers(): String + + data class ETrackersResponse(val trackers: List<ETracker>) { + data class ETracker( + val id: String?, + val hostnames: List<String>?, + val name: String?, + val exodusId: String? + ) + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/entities/AppWithCounts.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/AppWithCounts.kt new file mode 100644 index 0000000..4169ecc --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/AppWithCounts.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2023 MURENA SAS + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.domain.entities + +import android.graphics.drawable.Drawable +import foundation.e.privacymodules.permissions.data.ApplicationDescription + +data class AppWithCounts( + val appDesc: ApplicationDescription, + val packageName: String, + val uid: Int, + var label: CharSequence?, + var icon: Drawable?, + val isWhitelisted: Boolean = false, + val trackersCount: Int = 0, + val whiteListedTrackersCount: Int = 0, + val blockedLeaks: Int = 0, + val leaks: Int = 0, +) { + constructor( + app: ApplicationDescription, + isWhitelisted: Boolean, + trackersCount: Int, + whiteListedTrackersCount: Int, + blockedLeaks: Int, + leaks: Int, + ) : + this( + appDesc = app, + packageName = app.packageName, + uid = app.uid, + label = app.label, + icon = app.icon, + isWhitelisted = isWhitelisted, + trackersCount = trackersCount, + whiteListedTrackersCount = whiteListedTrackersCount, + blockedLeaks = blockedLeaks, + leaks = leaks + ) + + val blockedTrackersCount get() = if (isWhitelisted) 0 + else Math.max(trackersCount - whiteListedTrackersCount, 0) +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/entities/InternetPrivacyMode.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/InternetPrivacyMode.kt new file mode 100644 index 0000000..986e798 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/InternetPrivacyMode.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.domain.entities + +enum class InternetPrivacyMode { + REAL_IP, + HIDE_IP, + HIDE_IP_LOADING, + REAL_IP_LOADING; + + val isChecked get() = this == HIDE_IP || this == HIDE_IP_LOADING + + val isLoading get() = this == HIDE_IP_LOADING || this == REAL_IP_LOADING +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/entities/LocationMode.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/LocationMode.kt new file mode 100644 index 0000000..62581eb --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/LocationMode.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.domain.entities + +enum class LocationMode { + REAL_LOCATION, RANDOM_LOCATION, SPECIFIC_LOCATION +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/entities/MainFeatures.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/MainFeatures.kt new file mode 100644 index 0000000..c63d3ab --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/MainFeatures.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.domain.entities + +enum class MainFeatures { + TRACKERS_CONTROL, FAKE_LOCATION, IP_SCRAMBLING +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/entities/QuickPrivacyState.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/QuickPrivacyState.kt new file mode 100644 index 0000000..c21bb1d --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/QuickPrivacyState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.domain.entities + +enum class QuickPrivacyState { + DISABLED, ENABLED, FULL_ENABLED; + + fun isEnabled(): Boolean = this != DISABLED +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/entities/TrackerMode.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/TrackerMode.kt new file mode 100644 index 0000000..2033251 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/TrackerMode.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.domain.entities + +enum class TrackerMode { + DENIED, CUSTOM, VULNERABLE +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/entities/TrackersPeriodicStatistics.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/TrackersPeriodicStatistics.kt new file mode 100644 index 0000000..c0fa637 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/entities/TrackersPeriodicStatistics.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.domain.entities + +data class TrackersPeriodicStatistics( + val callsBlockedNLeaked: List<Pair<Int, Int>>, + val periods: List<String>, + val trackersCount: Int, + val graduations: List<String?>? = null +) diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/AppListUseCase.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/AppListUseCase.kt new file mode 100644 index 0000000..8d38ee8 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/AppListUseCase.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.domain.usecases + +import foundation.e.advancedprivacy.data.repositories.AppListsRepository +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import kotlinx.coroutines.flow.Flow + +class AppListUseCase( + private val appListsRepository: AppListsRepository +) { + val dummySystemApp = appListsRepository.dummySystemApp + fun getApp(uid: Int): ApplicationDescription { + return when (uid) { + dummySystemApp.uid -> dummySystemApp + appListsRepository.dummyCompatibilityApp.uid -> + appListsRepository.dummyCompatibilityApp + else -> appListsRepository.getApp(uid) ?: dummySystemApp + } + } + fun getAppsUsingInternet(): Flow<List<ApplicationDescription>> { + return appListsRepository.mainProfileApps() + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/FakeLocationStateUseCase.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/FakeLocationStateUseCase.kt new file mode 100644 index 0000000..9b99b95 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/FakeLocationStateUseCase.kt @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.domain.usecases + +import android.app.AppOpsManager +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Bundle +import android.util.Log +import foundation.e.advancedprivacy.data.repositories.LocalStateRepository +import foundation.e.advancedprivacy.domain.entities.LocationMode +import foundation.e.advancedprivacy.dummy.CityDataSource +import foundation.e.privacymodules.fakelocation.IFakeLocationModule +import foundation.e.privacymodules.permissions.PermissionsPrivacyModule +import foundation.e.privacymodules.permissions.data.AppOpModes +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlin.random.Random + +class FakeLocationStateUseCase( + private val fakeLocationModule: IFakeLocationModule, + private val permissionsModule: PermissionsPrivacyModule, + private val localStateRepository: LocalStateRepository, + private val citiesRepository: CityDataSource, + private val appDesc: ApplicationDescription, + private val appContext: Context, + coroutineScope: CoroutineScope +) { + companion object { + private const val TAG = "FakeLocationStateUseCase" + } + + private val _configuredLocationMode = MutableStateFlow<Triple<LocationMode, Float?, Float?>>(Triple(LocationMode.REAL_LOCATION, null, null)) + val configuredLocationMode: StateFlow<Triple<LocationMode, Float?, Float?>> = _configuredLocationMode + + init { + coroutineScope.launch { + localStateRepository.fakeLocationEnabled.collect { + applySettings(it, localStateRepository.fakeLocation) + } + } + } + + private val locationManager: LocationManager + get() = appContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager + + private fun hasAcquireLocationPermission(): Boolean { + return (appContext.checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) || + permissionsModule.toggleDangerousPermission(appDesc, android.Manifest.permission.ACCESS_FINE_LOCATION, true) + } + + private fun applySettings(isEnabled: Boolean, fakeLocation: Pair<Float, Float>, isSpecificLocation: Boolean = false) { + _configuredLocationMode.value = computeLocationMode(isEnabled, fakeLocation, isSpecificLocation) + + if (isEnabled && hasAcquireMockLocationPermission()) { + fakeLocationModule.startFakeLocation() + fakeLocationModule.setFakeLocation(fakeLocation.first.toDouble(), fakeLocation.second.toDouble()) + localStateRepository.locationMode.value = configuredLocationMode.value.first + } else { + fakeLocationModule.stopFakeLocation() + localStateRepository.locationMode.value = LocationMode.REAL_LOCATION + } + } + + private fun hasAcquireMockLocationPermission(): Boolean { + return (permissionsModule.getAppOpMode(appDesc, AppOpsManager.OPSTR_MOCK_LOCATION) == AppOpModes.ALLOWED) || + permissionsModule.setAppOpMode(appDesc, AppOpsManager.OPSTR_MOCK_LOCATION, AppOpModes.ALLOWED) + } + + fun setSpecificLocation(latitude: Float, longitude: Float) { + setFakeLocation(latitude to longitude, true) + } + + fun setRandomLocation() { + val randomIndex = Random.nextInt(citiesRepository.citiesLocationsList.size) + val location = citiesRepository.citiesLocationsList[randomIndex] + + setFakeLocation(location) + } + + private fun setFakeLocation(location: Pair<Float, Float>, isSpecificLocation: Boolean = false) { + localStateRepository.fakeLocation = location + localStateRepository.setFakeLocationEnabled(true) + applySettings(true, location, isSpecificLocation) + } + + fun stopFakeLocation() { + localStateRepository.setFakeLocationEnabled(false) + applySettings(false, localStateRepository.fakeLocation) + } + + private fun computeLocationMode( + isFakeLocationEnabled: Boolean, + fakeLocation: Pair<Float, Float>, + isSpecificLocation: Boolean = false, + ): Triple<LocationMode, Float?, Float?> { + return Triple( + when { + !isFakeLocationEnabled -> LocationMode.REAL_LOCATION + (fakeLocation in citiesRepository.citiesLocationsList && !isSpecificLocation) -> + LocationMode.RANDOM_LOCATION + else -> LocationMode.SPECIFIC_LOCATION + }, + fakeLocation.first, + fakeLocation.second + ) + } + + val currentLocation = MutableStateFlow<Location?>(null) + + private var localListener = object : LocationListener { + + override fun onLocationChanged(location: Location) { + currentLocation.update { previous -> + if ((previous?.time ?: 0) + 1800 < location.time || + (previous?.accuracy ?: Float.MAX_VALUE) > location.accuracy + ) { + location + } else { + previous + } + } + } + + // Deprecated since API 29, never called. + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} + + override fun onProviderEnabled(provider: String) { + reset() + } + + override fun onProviderDisabled(provider: String) { + reset() + } + + private fun reset() { + stopListeningLocation() + currentLocation.value = null + startListeningLocation() + } + } + + fun startListeningLocation(): Boolean { + return if (hasAcquireLocationPermission()) { + requestLocationUpdates() + true + } else false + } + + fun stopListeningLocation() { + locationManager.removeUpdates(localListener) + } + + private fun requestLocationUpdates() { + val networkProvider = LocationManager.NETWORK_PROVIDER + .takeIf { it in locationManager.allProviders } + val gpsProvider = LocationManager.GPS_PROVIDER + .takeIf { it in locationManager.allProviders } + + try { + networkProvider?.let { + locationManager.requestLocationUpdates( + it, + 1000L, + 0f, + localListener + ) + } + gpsProvider?.let { + locationManager.requestLocationUpdates( + it, + 1000L, + 0f, + localListener + ) + } + + networkProvider?.let { locationManager.getLastKnownLocation(it) } + ?: gpsProvider?.let { locationManager.getLastKnownLocation(it) } + ?.let { + localListener.onLocationChanged(it) + } + } catch (se: SecurityException) { + Log.e(TAG, "Missing permission", se) + } + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/GetQuickPrivacyStateUseCase.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/GetQuickPrivacyStateUseCase.kt new file mode 100644 index 0000000..475c05d --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/GetQuickPrivacyStateUseCase.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.domain.usecases + +import foundation.e.advancedprivacy.data.repositories.LocalStateRepository +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode +import foundation.e.advancedprivacy.domain.entities.LocationMode +import foundation.e.advancedprivacy.domain.entities.QuickPrivacyState +import foundation.e.advancedprivacy.domain.entities.TrackerMode +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +class GetQuickPrivacyStateUseCase( + private val localStateRepository: LocalStateRepository +) { + val quickPrivacyState: Flow<QuickPrivacyState> = combine( + localStateRepository.blockTrackers, + localStateRepository.areAllTrackersBlocked, + localStateRepository.locationMode, + localStateRepository.internetPrivacyMode + ) { isBlockTrackers, isAllTrackersBlocked, locationMode, internetPrivacyMode -> + when { + !isBlockTrackers && + locationMode == LocationMode.REAL_LOCATION && + internetPrivacyMode == InternetPrivacyMode.REAL_IP -> QuickPrivacyState.DISABLED + + isAllTrackersBlocked && + locationMode != LocationMode.REAL_LOCATION && + internetPrivacyMode in listOf( + InternetPrivacyMode.HIDE_IP, + InternetPrivacyMode.HIDE_IP_LOADING + ) -> QuickPrivacyState.FULL_ENABLED + + else -> QuickPrivacyState.ENABLED + } + } + + val trackerMode: Flow<TrackerMode> = combine( + localStateRepository.blockTrackers, + localStateRepository.areAllTrackersBlocked + ) { isBlockTrackers, isAllTrackersBlocked -> + when { + isBlockTrackers && isAllTrackersBlocked -> TrackerMode.DENIED + isBlockTrackers && !isAllTrackersBlocked -> TrackerMode.CUSTOM + else -> TrackerMode.VULNERABLE + } + } + + val isLocationHidden: Flow<Boolean> = localStateRepository.locationMode.map { locationMode -> + locationMode != LocationMode.REAL_LOCATION + } + + val locationMode: StateFlow<LocationMode> = localStateRepository.locationMode + + val ipScramblingMode: Flow<InternetPrivacyMode> = localStateRepository.internetPrivacyMode + + fun toggleTrackers() { + localStateRepository.setBlockTrackers(!localStateRepository.blockTrackers.value) + } + + fun toggleLocation() { + localStateRepository.setFakeLocationEnabled(!localStateRepository.fakeLocationEnabled.value) + } + + fun toggleIpScrambling() { + localStateRepository.setIpScramblingSetting(!localStateRepository.ipScramblingSetting.value) + } + + val otherVpnRunning: SharedFlow<ApplicationDescription> = localStateRepository.otherVpnRunning +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/IpScramblingStateUseCase.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/IpScramblingStateUseCase.kt new file mode 100644 index 0000000..8c94602 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/IpScramblingStateUseCase.kt @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2021 E FOUNDATION, 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.data.repositories.LocalStateRepository +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode.HIDE_IP +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode.HIDE_IP_LOADING +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode.REAL_IP +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode.REAL_IP_LOADING +import foundation.e.privacymodules.ipscramblermodule.IIpScramblerModule +import foundation.e.privacymodules.permissions.IPermissionsPrivacyModule +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class IpScramblingStateUseCase( + private val ipScramblerModule: IIpScramblerModule, + private val permissionsPrivacyModule: IPermissionsPrivacyModule, + private val appDesc: ApplicationDescription, + private val localStateRepository: LocalStateRepository, + private val appListsRepository: AppListsRepository, + private val coroutineScope: CoroutineScope +) { + val internetPrivacyMode: StateFlow<InternetPrivacyMode> = callbackFlow { + val listener = object : IIpScramblerModule.Listener { + override fun onStatusChanged(newStatus: IIpScramblerModule.Status) { + trySend(map(newStatus)) + } + + override fun log(message: String) {} + override fun onTrafficUpdate( + upload: Long, + download: Long, + read: Long, + write: Long + ) { + } + } + ipScramblerModule.addListener(listener) + ipScramblerModule.requestStatus() + awaitClose { ipScramblerModule.removeListener(listener) } + }.stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = REAL_IP + ) + + init { + coroutineScope.launch(Dispatchers.Default) { + localStateRepository.ipScramblingSetting.collect { + applySettings(it) + } + } + + coroutineScope.launch { + internetPrivacyMode.collect { localStateRepository.internetPrivacyMode.value = it } + } + } + + fun toggle(hideIp: Boolean) { + localStateRepository.setIpScramblingSetting(enabled = hideIp) + } + + private fun getHiddenPackageNames(): List<String> { + return appListsRepository.getMainProfileHiddenSystemApps().map { it.packageName } + } + + val bypassTorApps: Set<String> get() { + var whitelist = ipScramblerModule.appList + if (getHiddenPackageNames().any { it in whitelist }) { + val mutable = whitelist.toMutableSet() + mutable.removeAll(getHiddenPackageNames()) + mutable.add(appListsRepository.dummySystemApp.packageName) + whitelist = mutable + } + if (AppListsRepository.compatibiltyPNames.any { it in whitelist }) { + val mutable = whitelist.toMutableSet() + mutable.removeAll(AppListsRepository.compatibiltyPNames) + mutable.add(appListsRepository.dummyCompatibilityApp.packageName) + whitelist = mutable + } + return whitelist + } + + fun toggleBypassTor(packageName: String) { + val visibleList = bypassTorApps.toMutableSet() + val rawList = ipScramblerModule.appList.toMutableSet() + + if (visibleList.contains(packageName)) { + if (packageName == appListsRepository.dummySystemApp.packageName) { + rawList.removeAll(getHiddenPackageNames()) + } else if (packageName == appListsRepository.dummyCompatibilityApp.packageName) { + rawList.removeAll(AppListsRepository.compatibiltyPNames) + } else { + rawList.remove(packageName) + } + } else { + if (packageName == appListsRepository.dummySystemApp.packageName) { + rawList.addAll(getHiddenPackageNames()) + } else if (packageName == appListsRepository.dummyCompatibilityApp.packageName) { + rawList.addAll(AppListsRepository.compatibiltyPNames) + } else { + rawList.add(packageName) + } + } + ipScramblerModule.appList = rawList + } + + private fun applySettings(isIpScramblingEnabled: Boolean) { + val currentMode = localStateRepository.internetPrivacyMode.value + when { + isIpScramblingEnabled && currentMode in setOf(REAL_IP, REAL_IP_LOADING) -> + applyStartIpScrambling() + + !isIpScramblingEnabled && currentMode in setOf(HIDE_IP, HIDE_IP_LOADING) -> + ipScramblerModule.stop() + + else -> {} + } + } + + private fun applyStartIpScrambling() { + ipScramblerModule.prepareAndroidVpn()?.let { + permissionsPrivacyModule.setVpnPackageAuthorization(appDesc.packageName) + permissionsPrivacyModule.getAlwaysOnVpnPackage() + }?.let { + coroutineScope.launch { + localStateRepository.emitOtherVpnRunning( + permissionsPrivacyModule.getApplicationDescription(packageName = it, withIcon = false) + ) + } + localStateRepository.setIpScramblingSetting(enabled = false) + } ?: run { + ipScramblerModule.start(enableNotification = false) + } + } + + private fun map(status: IIpScramblerModule.Status): InternetPrivacyMode { + return when (status) { + IIpScramblerModule.Status.OFF -> REAL_IP + IIpScramblerModule.Status.ON -> HIDE_IP + IIpScramblerModule.Status.STARTING -> HIDE_IP_LOADING + IIpScramblerModule.Status.STOPPING, + IIpScramblerModule.Status.START_DISABLED -> REAL_IP_LOADING + } + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/ShowFeaturesWarningUseCase.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/ShowFeaturesWarningUseCase.kt new file mode 100644 index 0000000..11bce86 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/ShowFeaturesWarningUseCase.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.domain.usecases + +import foundation.e.advancedprivacy.data.repositories.LocalStateRepository +import foundation.e.advancedprivacy.domain.entities.MainFeatures +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.dropWhile +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge + +class ShowFeaturesWarningUseCase( + private val localStateRepository: LocalStateRepository +) { + + fun showWarning(): Flow<MainFeatures> { + return merge( + localStateRepository.blockTrackers.drop(1).dropWhile { !it } + .filter { it && !localStateRepository.hideWarningTrackers } + .map { MainFeatures.TRACKERS_CONTROL }, + localStateRepository.fakeLocationEnabled.drop(1).dropWhile { !it } + .filter { it && !localStateRepository.hideWarningLocation } + .map { MainFeatures.FAKE_LOCATION }, + localStateRepository.ipScramblingSetting.drop(1).dropWhile { !it } + .filter { it && !localStateRepository.hideWarningIpScrambling } + .map { MainFeatures.IP_SCRAMBLING } + ) + } + + fun doNotShowAgain(feature: MainFeatures) { + when (feature) { + MainFeatures.TRACKERS_CONTROL -> localStateRepository.hideWarningTrackers = true + MainFeatures.FAKE_LOCATION -> localStateRepository.hideWarningLocation = true + MainFeatures.IP_SCRAMBLING -> localStateRepository.hideWarningIpScrambling = true + } + } +} 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 new file mode 100644 index 0000000..882d53f --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersStateUseCase.kt @@ -0,0 +1,105 @@ +/* + * 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.domain.usecases + +import foundation.e.advancedprivacy.data.repositories.AppListsRepository +import foundation.e.advancedprivacy.data.repositories.LocalStateRepository +import foundation.e.advancedprivacy.data.repositories.TrackersRepository +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import foundation.e.privacymodules.trackers.api.IBlockTrackersPrivacyModule +import foundation.e.privacymodules.trackers.api.ITrackTrackersPrivacyModule +import foundation.e.privacymodules.trackers.api.Tracker +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class TrackersStateUseCase( + private val blockTrackersPrivacyModule: IBlockTrackersPrivacyModule, + private val trackersPrivacyModule: ITrackTrackersPrivacyModule, + private val localStateRepository: LocalStateRepository, + private val trackersRepository: TrackersRepository, + private val appListsRepository: AppListsRepository, + private val coroutineScope: CoroutineScope +) { + init { + trackersPrivacyModule.start( + trackers = trackersRepository.trackers, + getAppByAPId = appListsRepository::getApp, + getAppByUid = appListsRepository::getApp, + enableNotification = false + ) + coroutineScope.launch { + localStateRepository.blockTrackers.collect { enabled -> + if (enabled) { + blockTrackersPrivacyModule.enableBlocking() + } else { + blockTrackersPrivacyModule.disableBlocking() + } + updateAllTrackersBlockedState() + } + } + } + + private fun updateAllTrackersBlockedState() { + localStateRepository.areAllTrackersBlocked.value = blockTrackersPrivacyModule.isBlockingEnabled() && + blockTrackersPrivacyModule.isWhiteListEmpty() + } + + fun isWhitelisted(app: ApplicationDescription): Boolean { + return isWhitelisted(app, appListsRepository, blockTrackersPrivacyModule) + } + + fun toggleAppWhitelist(app: ApplicationDescription, isWhitelisted: Boolean) { + appListsRepository.applyForHiddenApps(app) { + blockTrackersPrivacyModule.setWhiteListed(it, isWhitelisted) + } + updateAllTrackersBlockedState() + } + + fun blockTracker(app: ApplicationDescription, tracker: Tracker, isBlocked: Boolean) { + appListsRepository.applyForHiddenApps(app) { + blockTrackersPrivacyModule.setWhiteListed(tracker, it, !isBlocked) + } + updateAllTrackersBlockedState() + } + + fun clearWhitelist(app: ApplicationDescription) { + appListsRepository.applyForHiddenApps( + app, + blockTrackersPrivacyModule::clearWhiteList + ) + updateAllTrackersBlockedState() + } + + fun updateTrackers() = coroutineScope.launch { + trackersRepository.update() + trackersPrivacyModule.start( + trackers = trackersRepository.trackers, + getAppByAPId = appListsRepository::getApp, + getAppByUid = appListsRepository::getApp, + enableNotification = false + ) + } +} + +fun isWhitelisted( + app: ApplicationDescription, + appListsRepository: AppListsRepository, + blockTrackersPrivacyModule: IBlockTrackersPrivacyModule +): Boolean { + return appListsRepository.anyForHiddenApps(app, blockTrackersPrivacyModule::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 new file mode 100644 index 0000000..43e4496 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/TrackersStatisticsUseCase.kt @@ -0,0 +1,278 @@ +/* + * 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.domain.usecases + +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.TrackersPeriodicStatistics +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import foundation.e.privacymodules.trackers.api.IBlockTrackersPrivacyModule +import foundation.e.privacymodules.trackers.api.ITrackTrackersPrivacyModule +import foundation.e.privacymodules.trackers.api.Tracker +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class TrackersStatisticsUseCase( + private val trackTrackersPrivacyModule: ITrackTrackersPrivacyModule, + private val blockTrackersPrivacyModule: IBlockTrackersPrivacyModule, + private val appListsRepository: AppListsRepository, + private val resources: Resources +) { + fun initAppList() { + appListsRepository.apps() + } + + private fun rawUpdates(): Flow<Unit> = callbackFlow { + val listener = object : ITrackTrackersPrivacyModule.Listener { + override fun onNewData() { + trySend(Unit) + } + } + trackTrackersPrivacyModule.addListener(listener) + awaitClose { trackTrackersPrivacyModule.removeListener(listener) } + } + + @OptIn(FlowPreview::class) + fun listenUpdates(debounce: Duration = 1.seconds) = rawUpdates() + .throttleFirst(windowDuration = debounce) + .onStart { emit(Unit) } + + fun getDayStatistics(): Pair<TrackersPeriodicStatistics, Int> { + return TrackersPeriodicStatistics( + callsBlockedNLeaked = trackTrackersPrivacyModule.getPastDayTrackersCalls(), + periods = buildDayLabels(), + trackersCount = trackTrackersPrivacyModule.getPastDayTrackersCount(), + graduations = buildDayGraduations(), + ) to trackTrackersPrivacyModule.getTrackersCount() + } + + fun getNonBlockedTrackersCount(): Flow<Int> { + return if (blockTrackersPrivacyModule.isBlockingEnabled()) + appListsRepository.allApps().map { apps -> + val whiteListedTrackers = mutableSetOf<Tracker>() + val whiteListedApps = blockTrackersPrivacyModule.getWhiteListedApp() + apps.forEach { app -> + if (app in whiteListedApps) { + whiteListedTrackers.addAll(trackTrackersPrivacyModule.getTrackersForApp(app)) + } else { + whiteListedTrackers.addAll(blockTrackersPrivacyModule.getWhiteList(app)) + } + } + whiteListedTrackers.size + } + else flowOf(trackTrackersPrivacyModule.getTrackersCount()) + } + + fun getMostLeakedApp(): ApplicationDescription? { + return trackTrackersPrivacyModule.getPastDayMostLeakedApp() + } + + fun getDayTrackersCalls() = trackTrackersPrivacyModule.getPastDayTrackersCalls() + + fun getDayTrackersCount() = trackTrackersPrivacyModule.getPastDayTrackersCount() + + private fun buildDayGraduations(): List<String?> { + val formatter = DateTimeFormatter.ofPattern( + resources.getString(R.string.trackers_graph_hours_period_format) + ) + + val periods = mutableListOf<String?>() + var end = ZonedDateTime.now() + for (i in 1..24) { + val start = end.truncatedTo(ChronoUnit.HOURS) + periods.add(if (start.hour % 6 == 0) formatter.format(start) else null) + end = start.minus(1, ChronoUnit.MINUTES) + } + return periods.reversed() + } + + private fun buildDayLabels(): List<String> { + val formatter = DateTimeFormatter.ofPattern( + resources.getString(R.string.trackers_graph_hours_period_format) + ) + val periods = mutableListOf<String>() + var end = ZonedDateTime.now() + for (i in 1..24) { + val start = end.truncatedTo(ChronoUnit.HOURS) + periods.add("${formatter.format(start)} - ${formatter.format(end)}") + end = start.minus(1, ChronoUnit.MINUTES) + } + return periods.reversed() + } + + private fun buildMonthLabels(): List<String> { + val formater = DateTimeFormatter.ofPattern( + resources.getString(R.string.trackers_graph_days_period_format) + ) + val periods = mutableListOf<String>() + var day = ZonedDateTime.now().truncatedTo(ChronoUnit.DAYS) + for (i in 1..30) { + periods.add(formater.format(day)) + day = day.minus(1, ChronoUnit.DAYS) + } + return periods.reversed() + } + + private fun buildYearLabels(): List<String> { + val formater = DateTimeFormatter.ofPattern( + resources.getString(R.string.trackers_graph_months_period_format) + ) + val periods = mutableListOf<String>() + var month = ZonedDateTime.now().truncatedTo(ChronoUnit.DAYS).withDayOfMonth(1) + for (i in 1..12) { + periods.add(formater.format(month)) + month = month.minus(1, ChronoUnit.MONTHS) + } + return periods.reversed() + } + + fun getDayMonthYearStatistics(): Triple<TrackersPeriodicStatistics, TrackersPeriodicStatistics, TrackersPeriodicStatistics> { + return with(trackTrackersPrivacyModule) { + Triple( + TrackersPeriodicStatistics( + callsBlockedNLeaked = getPastDayTrackersCalls(), + periods = buildDayLabels(), + trackersCount = getPastDayTrackersCount() + ), + TrackersPeriodicStatistics( + callsBlockedNLeaked = getPastMonthTrackersCalls(), + periods = buildMonthLabels(), + trackersCount = getPastMonthTrackersCount() + ), + TrackersPeriodicStatistics( + callsBlockedNLeaked = getPastYearTrackersCalls(), + periods = buildYearLabels(), + trackersCount = getPastYearTrackersCount() + ) + ) + } + } + + fun getTrackersWithWhiteList(app: ApplicationDescription): List<Pair<Tracker, Boolean>> { + return appListsRepository.mapReduceForHiddenApps( + app = app, + map = { appDesc: ApplicationDescription -> + ( + trackTrackersPrivacyModule.getTrackersForApp(appDesc) to + blockTrackersPrivacyModule.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 { + return appListsRepository.mapReduceForHiddenApps( + app = app, + map = { appDesc: ApplicationDescription -> + blockTrackersPrivacyModule.getWhiteList(appDesc).isEmpty() + }, + reduce = { areEmpty -> areEmpty.all { it } } + ) + } + + fun getCalls(app: ApplicationDescription): Pair<Int, Int> { + return appListsRepository.mapReduceForHiddenApps( + app = app, + map = trackTrackersPrivacyModule::getPastDayTrackersCallsForApp, + reduce = { zip -> + zip.unzip().let { (blocked, leaked) -> + blocked.sum() to leaked.sum() + } + } + ) + } + + fun getAppsWithCounts(): Flow<List<AppWithCounts>> { + val trackersCounts = trackTrackersPrivacyModule.getTrackersCountByApp() + val hiddenAppsTrackersWithWhiteList = + getTrackersWithWhiteList(appListsRepository.dummySystemApp) + val acAppsTrackersWithWhiteList = + getTrackersWithWhiteList(appListsRepository.dummyCompatibilityApp) + + return appListsRepository.apps() + .map { apps -> + val callsByApp = trackTrackersPrivacyModule.getPastDayTrackersCallsByApps() + 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 = !blockTrackersPrivacyModule.isBlockingEnabled() || + isWhitelisted(app, appListsRepository, blockTrackersPrivacyModule), + 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 -> + blockTrackersPrivacyModule.getWhiteList(app).size + }, + blockedLeaks = calls.first, + leaks = calls.second + ) + } + .sortedWith(mostLeakedAppsComparator) + } + } + + 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/domain/usecases/UpdateWidgetUseCase.kt b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/UpdateWidgetUseCase.kt new file mode 100644 index 0000000..94c734c --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/domain/usecases/UpdateWidgetUseCase.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.domain.usecases + +import foundation.e.advancedprivacy.data.repositories.LocalStateRepository +import foundation.e.privacymodules.trackers.api.ITrackTrackersPrivacyModule + +class UpdateWidgetUseCase( + private val localStateRepository: LocalStateRepository, + private val trackTrackersPrivacyModule: ITrackTrackersPrivacyModule, +) { + init { + trackTrackersPrivacyModule.addListener(object : ITrackTrackersPrivacyModule.Listener { + override fun onNewData() { + } + }) + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardFragment.kt b/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardFragment.kt new file mode 100644 index 0000000..b30935c --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardFragment.kt @@ -0,0 +1,307 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.dashboard + +import android.content.Intent +import android.os.Bundle +import android.text.Html +import android.text.Html.FROM_HTML_MODE_LEGACY +import android.view.View +import android.widget.Toast +import androidx.core.content.ContextCompat.getColor +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import androidx.fragment.app.commit +import androidx.fragment.app.replace +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import foundation.e.advancedprivacy.AdvancedPrivacyApplication +import foundation.e.advancedprivacy.DependencyContainer +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.common.GraphHolder +import foundation.e.advancedprivacy.common.NavToolbarFragment +import foundation.e.advancedprivacy.databinding.FragmentDashboardBinding +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode +import foundation.e.advancedprivacy.domain.entities.LocationMode +import foundation.e.advancedprivacy.domain.entities.QuickPrivacyState +import foundation.e.advancedprivacy.domain.entities.TrackerMode +import foundation.e.advancedprivacy.features.dashboard.DashboardViewModel.Action +import foundation.e.advancedprivacy.features.dashboard.DashboardViewModel.SingleEvent +import foundation.e.advancedprivacy.features.internetprivacy.InternetPrivacyFragment +import foundation.e.advancedprivacy.features.location.FakeLocationFragment +import foundation.e.advancedprivacy.features.trackers.TrackersFragment +import foundation.e.advancedprivacy.features.trackers.apptrackers.AppTrackersFragment +import kotlinx.coroutines.launch + +class DashboardFragment : NavToolbarFragment(R.layout.fragment_dashboard) { + companion object { + private const val PARAM_HIGHLIGHT_INDEX = "PARAM_HIGHLIGHT_INDEX" + fun buildArgs(highlightIndex: Int): Bundle = bundleOf( + PARAM_HIGHLIGHT_INDEX to highlightIndex + ) + } + + private val dependencyContainer: DependencyContainer by lazy { + (this.requireActivity().application as AdvancedPrivacyApplication).dependencyContainer + } + + private val viewModel: DashboardViewModel by viewModels { + dependencyContainer.viewModelsFactory + } + + private var graphHolder: GraphHolder? = null + + private var _binding: FragmentDashboardBinding? = null + private val binding get() = _binding!! + + private var highlightIndexOnStart: Int? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + highlightIndexOnStart = arguments?.getInt(PARAM_HIGHLIGHT_INDEX, -1) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentDashboardBinding.bind(view) + + graphHolder = GraphHolder(binding.graph, requireContext()) + + binding.leakingAppButton.setOnClickListener { + viewModel.submitAction(Action.ShowMostLeakedApp) + } + binding.toggleTrackers.setOnClickListener { + viewModel.submitAction(Action.ToggleTrackers) + } + binding.toggleLocation.setOnClickListener { + viewModel.submitAction(Action.ToggleLocation) + } + binding.toggleIpscrambling.setOnClickListener { + viewModel.submitAction(Action.ToggleIpScrambling) + } + binding.myLocation.container.setOnClickListener { + viewModel.submitAction(Action.ShowFakeMyLocationAction) + } + binding.internetActivityPrivacy.container.setOnClickListener { + viewModel.submitAction(Action.ShowInternetActivityPrivacyAction) + } + binding.appsPermissions.container.setOnClickListener { + viewModel.submitAction(Action.ShowAppsPermissions) + } + + binding.amITracked.container.setOnClickListener { + viewModel.submitAction(Action.ShowTrackers) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + render(viewModel.state.value) + viewModel.state.collect(::render) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect { event -> + when (event) { + is SingleEvent.NavigateToLocationSingleEvent -> { + requireActivity().supportFragmentManager.commit { + replace<FakeLocationFragment>(R.id.container) + setReorderingAllowed(true) + addToBackStack("dashboard") + } + } + is SingleEvent.NavigateToInternetActivityPrivacySingleEvent -> { + requireActivity().supportFragmentManager.commit { + replace<InternetPrivacyFragment>(R.id.container) + setReorderingAllowed(true) + addToBackStack("dashboard") + } + } + is SingleEvent.NavigateToPermissionsSingleEvent -> { + val intent = Intent("android.intent.action.MANAGE_PERMISSIONS") + requireActivity().startActivity(intent) + } + SingleEvent.NavigateToTrackersSingleEvent -> { + requireActivity().supportFragmentManager.commit { + replace<TrackersFragment>(R.id.container) + setReorderingAllowed(true) + addToBackStack("dashboard") + } + } + is SingleEvent.NavigateToAppDetailsEvent -> { + requireActivity().supportFragmentManager.commit { + replace<AppTrackersFragment>( + R.id.container, + args = AppTrackersFragment.buildArgs( + event.appDesc.label.toString(), + event.appDesc.packageName, + event.appDesc.uid + ) + ) + setReorderingAllowed(true) + addToBackStack("dashboard") + } + } + is SingleEvent.ToastMessageSingleEvent -> + Toast.makeText( + requireContext(), + getString(event.message, *event.args.toTypedArray()), + Toast.LENGTH_LONG + ).show() + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } + } + } + + override fun getTitle(): String { + return getString(R.string.dashboard_title) + } + + private fun render(state: DashboardState) { + binding.stateLabel.text = getString( + when (state.quickPrivacyState) { + QuickPrivacyState.DISABLED -> R.string.dashboard_state_title_off + QuickPrivacyState.FULL_ENABLED -> R.string.dashboard_state_title_on + QuickPrivacyState.ENABLED -> R.string.dashboard_state_title_custom + } + ) + + binding.stateIcon.setImageResource( + if (state.quickPrivacyState.isEnabled()) R.drawable.ic_shield_on + else R.drawable.ic_shield_off + ) + + binding.toggleTrackers.isChecked = state.trackerMode != TrackerMode.VULNERABLE + + binding.stateTrackers.text = getString( + when (state.trackerMode) { + TrackerMode.DENIED -> R.string.dashboard_state_trackers_on + TrackerMode.VULNERABLE -> R.string.dashboard_state_trackers_off + TrackerMode.CUSTOM -> R.string.dashboard_state_trackers_custom + } + ) + binding.stateTrackers.setTextColor( + getColor( + requireContext(), + if (state.trackerMode == TrackerMode.VULNERABLE) R.color.red_off + else R.color.green_valid + ) + ) + + binding.toggleLocation.isChecked = state.isLocationHidden + + binding.stateGeolocation.text = getString( + if (state.isLocationHidden) R.string.dashboard_state_geolocation_on + else R.string.dashboard_state_geolocation_off + ) + binding.stateGeolocation.setTextColor( + getColor( + requireContext(), + if (state.isLocationHidden) R.color.green_valid + else R.color.red_off + ) + ) + + binding.toggleIpscrambling.isChecked = state.ipScramblingMode.isChecked + val isLoading = state.ipScramblingMode.isLoading + + binding.stateIpAddress.text = getString( + if (state.ipScramblingMode == InternetPrivacyMode.HIDE_IP) R.string.dashboard_state_ipaddress_on + else R.string.dashboard_state_ipaddress_off + ) + + binding.stateIpAddressLoader.visibility = if (isLoading) View.VISIBLE else View.GONE + binding.stateIpAddress.visibility = if (!isLoading) View.VISIBLE else View.GONE + + binding.stateIpAddress.setTextColor( + getColor( + requireContext(), + if (state.ipScramblingMode == InternetPrivacyMode.HIDE_IP) R.color.green_valid + else R.color.red_off + ) + ) + + if (state.dayStatistics?.all { it.first == 0 && it.second == 0 } == true) { + binding.graph.visibility = View.INVISIBLE + binding.graphLegend.isVisible = false + binding.leakingAppButton.isVisible = false + binding.graphEmpty.isVisible = true + } else { + binding.graph.isVisible = true + binding.graphLegend.isVisible = true + binding.leakingAppButton.isVisible = true + binding.graphEmpty.isVisible = false + state.dayStatistics?.let { graphHolder?.data = it } + state.dayLabels?.let { graphHolder?.labels = it } + state.dayGraduations?.let { graphHolder?.graduations = it } + + binding.graphLegend.text = Html.fromHtml( + getString( + R.string.dashboard_graph_trackers_legend, + state.leakedTrackersCount?.toString() ?: "No" + ), + FROM_HTML_MODE_LEGACY + ) + + highlightIndexOnStart?.let { + binding.graph.post { + graphHolder?.highlightIndex(it) + } + highlightIndexOnStart = null + } + } + + if (state.allowedTrackersCount != null && state.trackersCount != null) { + binding.amITracked.subTitle = getString(R.string.dashboard_am_i_tracked_subtitle, state.trackersCount, state.allowedTrackersCount) + } else { + binding.amITracked.subTitle = "" + } + + binding.myLocation.subTitle = getString( + when (state.locationMode) { + LocationMode.REAL_LOCATION -> R.string.dashboard_location_subtitle_off + LocationMode.SPECIFIC_LOCATION -> R.string.dashboard_location_subtitle_specific + LocationMode.RANDOM_LOCATION -> R.string.dashboard_location_subtitle_random + } + ) + + binding.internetActivityPrivacy.subTitle = getString( + if (state.ipScramblingMode == InternetPrivacyMode.HIDE_IP) R.string.dashboard_internet_activity_privacy_subtitle_on + else R.string.dashboard_internet_activity_privacy_subtitle_off + ) + + binding.executePendingBindings() + } + + override fun onDestroyView() { + super.onDestroyView() + graphHolder = null + _binding = null + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardState.kt b/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardState.kt new file mode 100644 index 0000000..8fc8767 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardState.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.dashboard + +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode +import foundation.e.advancedprivacy.domain.entities.LocationMode +import foundation.e.advancedprivacy.domain.entities.QuickPrivacyState +import foundation.e.advancedprivacy.domain.entities.TrackerMode + +data class DashboardState( + val quickPrivacyState: QuickPrivacyState = QuickPrivacyState.DISABLED, + val trackerMode: TrackerMode = TrackerMode.VULNERABLE, + val isLocationHidden: Boolean = false, + val ipScramblingMode: InternetPrivacyMode = InternetPrivacyMode.REAL_IP_LOADING, + val locationMode: LocationMode = LocationMode.REAL_LOCATION, + val leakedTrackersCount: Int? = null, + val trackersCount: Int? = null, + val allowedTrackersCount: Int? = null, + val dayStatistics: List<Pair<Int, Int>>? = null, + val dayLabels: List<String>? = null, + val dayGraduations: List<String?>? = null, +) diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardViewModel.kt b/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..d82b073 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/dashboard/DashboardViewModel.kt @@ -0,0 +1,158 @@ +/* +* Copyright (C) 2023 MURENA SAS + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.dashboard + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.domain.usecases.GetQuickPrivacyStateUseCase +import foundation.e.advancedprivacy.domain.usecases.TrackersStatisticsUseCase +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class DashboardViewModel( + private val getPrivacyStateUseCase: GetQuickPrivacyStateUseCase, + private val trackersStatisticsUseCase: TrackersStatisticsUseCase, +) : ViewModel() { + + private val _state = MutableStateFlow(DashboardState()) + val state = _state.asStateFlow() + + private val _singleEvents = MutableSharedFlow<SingleEvent>() + val singleEvents = _singleEvents.asSharedFlow() + + init { + viewModelScope.launch(Dispatchers.IO) { trackersStatisticsUseCase.initAppList() } + } + + suspend fun doOnStartedState() = withContext(Dispatchers.IO) { + merge( + getPrivacyStateUseCase.quickPrivacyState.map { + _state.update { s -> s.copy(quickPrivacyState = it) } + }, + getPrivacyStateUseCase.ipScramblingMode.map { + _state.update { s -> s.copy(ipScramblingMode = it) } + }, + trackersStatisticsUseCase.listenUpdates().flatMapLatest { + fetchStatistics() + }, + getPrivacyStateUseCase.trackerMode.map { + _state.update { s -> s.copy(trackerMode = it) } + }, + getPrivacyStateUseCase.isLocationHidden.map { + _state.update { s -> s.copy(isLocationHidden = it) } + }, + getPrivacyStateUseCase.locationMode.map { + _state.update { s -> s.copy(locationMode = it) } + }, + getPrivacyStateUseCase.otherVpnRunning.map { + _singleEvents.emit( + SingleEvent.ToastMessageSingleEvent( + R.string.ipscrambling_error_always_on_vpn_already_running, + listOf(it.label ?: "") + ) + ) + } + ).collect {} + } + + fun submitAction(action: Action) = viewModelScope.launch { + when (action) { + is Action.ToggleTrackers -> { + getPrivacyStateUseCase.toggleTrackers() + // Add delay here to prevent race condition with trackers state. + delay(200) + fetchStatistics().first() + } + is Action.ToggleLocation -> getPrivacyStateUseCase.toggleLocation() + is Action.ToggleIpScrambling -> getPrivacyStateUseCase.toggleIpScrambling() + is Action.ShowFakeMyLocationAction -> + _singleEvents.emit(SingleEvent.NavigateToLocationSingleEvent) + is Action.ShowAppsPermissions -> + _singleEvents.emit(SingleEvent.NavigateToPermissionsSingleEvent) + is Action.ShowInternetActivityPrivacyAction -> + _singleEvents.emit(SingleEvent.NavigateToInternetActivityPrivacySingleEvent) + is Action.ShowTrackers -> + _singleEvents.emit(SingleEvent.NavigateToTrackersSingleEvent) + is Action.ShowMostLeakedApp -> actionShowMostLeakedApp() + } + } + + private suspend fun fetchStatistics(): Flow<Unit> = withContext(Dispatchers.IO) { + trackersStatisticsUseCase.getNonBlockedTrackersCount().map { nonBlockedTrackersCount -> + trackersStatisticsUseCase.getDayStatistics().let { (dayStatistics, trackersCount) -> + _state.update { s -> + s.copy( + dayStatistics = dayStatistics.callsBlockedNLeaked, + dayLabels = dayStatistics.periods, + dayGraduations = dayStatistics.graduations, + leakedTrackersCount = dayStatistics.trackersCount, + trackersCount = trackersCount, + allowedTrackersCount = nonBlockedTrackersCount + ) + } + } + } + } + + private suspend fun actionShowMostLeakedApp() = withContext(Dispatchers.IO) { + _singleEvents.emit( + trackersStatisticsUseCase.getMostLeakedApp()?.let { + SingleEvent.NavigateToAppDetailsEvent(appDesc = it) + } ?: SingleEvent.NavigateToTrackersSingleEvent + ) + } + + sealed class SingleEvent { + object NavigateToTrackersSingleEvent : SingleEvent() + object NavigateToInternetActivityPrivacySingleEvent : SingleEvent() + object NavigateToLocationSingleEvent : SingleEvent() + object NavigateToPermissionsSingleEvent : SingleEvent() + data class NavigateToAppDetailsEvent(val appDesc: ApplicationDescription) : SingleEvent() + data class ToastMessageSingleEvent( + @StringRes val message: Int, + val args: List<Any> = emptyList() + ) : SingleEvent() + } + + sealed class Action { + object ToggleTrackers : Action() + object ToggleLocation : Action() + object ToggleIpScrambling : Action() + object ShowFakeMyLocationAction : Action() + object ShowInternetActivityPrivacyAction : Action() + object ShowAppsPermissions : Action() + object ShowTrackers : Action() + object ShowMostLeakedApp : Action() + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyFragment.kt b/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyFragment.kt new file mode 100644 index 0000000..07da82a --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyFragment.kt @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.internetprivacy + +import android.os.Bundle +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Toast +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import foundation.e.advancedprivacy.AdvancedPrivacyApplication +import foundation.e.advancedprivacy.DependencyContainer +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.common.NavToolbarFragment +import foundation.e.advancedprivacy.common.ToggleAppsAdapter +import foundation.e.advancedprivacy.common.setToolTipForAsterisk +import foundation.e.advancedprivacy.databinding.FragmentInternetActivityPolicyBinding +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode +import kotlinx.coroutines.launch +import java.util.Locale + +class InternetPrivacyFragment : NavToolbarFragment(R.layout.fragment_internet_activity_policy) { + + private val dependencyContainer: DependencyContainer by lazy { + (this.requireActivity().application as AdvancedPrivacyApplication).dependencyContainer + } + + private val viewModel: InternetPrivacyViewModel by viewModels { + dependencyContainer.viewModelsFactory + } + + private var _binding: FragmentInternetActivityPolicyBinding? = null + private val binding get() = _binding!! + + private fun displayToast(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT) + .show() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentInternetActivityPolicyBinding.bind(view) + + binding.apps.apply { + layoutManager = LinearLayoutManager(requireContext()) + setHasFixedSize(true) + adapter = ToggleAppsAdapter(R.layout.ipscrambling_item_app_toggle) { packageName -> + viewModel.submitAction( + InternetPrivacyViewModel.Action.ToggleAppIpScrambled(packageName) + ) + } + } + + binding.radioUseRealIp.radiobutton.setOnClickListener { + viewModel.submitAction(InternetPrivacyViewModel.Action.UseRealIPAction) + } + + binding.radioUseHiddenIp.radiobutton.setOnClickListener { + viewModel.submitAction(InternetPrivacyViewModel.Action.UseHiddenIPAction) + } + + setToolTipForAsterisk( + textView = binding.ipscramblingSelectApps, + textId = R.string.ipscrambling_select_app, + tooltipTextId = R.string.ipscrambling_app_list_infos + ) + + binding.ipscramblingSelectLocation.apply { + adapter = ArrayAdapter( + requireContext(), android.R.layout.simple_spinner_item, + viewModel.availablesLocationsIds.map { + if (it == "") { + getString(R.string.ipscrambling_any_location) + } else { + Locale("", it).displayCountry + } + } + ).apply { + setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + } + + onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parentView: AdapterView<*>, + selectedItemView: View?, + position: Int, + id: Long + ) { + viewModel.submitAction( + InternetPrivacyViewModel.Action.SelectLocationAction( + position + ) + ) + } + + override fun onNothingSelected(parentView: AdapterView<*>?) {} + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + render(viewModel.state.value) + viewModel.state.collect(::render) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect { event -> + when (event) { + is InternetPrivacyViewModel.SingleEvent.ErrorEvent -> { + displayToast(getString(event.errorResId, *event.args.toTypedArray())) + } + } + } + } + } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } + } + } + + override fun getTitle(): String = getString(R.string.ipscrambling_title) + + private fun render(state: InternetPrivacyState) { + binding.radioUseHiddenIp.radiobutton.apply { + isChecked = state.mode in listOf( + InternetPrivacyMode.HIDE_IP, + InternetPrivacyMode.HIDE_IP_LOADING + ) + isEnabled = state.mode != InternetPrivacyMode.HIDE_IP_LOADING + } + binding.radioUseRealIp.radiobutton.apply { + isChecked = + state.mode in listOf( + InternetPrivacyMode.REAL_IP, + InternetPrivacyMode.REAL_IP_LOADING + ) + isEnabled = state.mode != InternetPrivacyMode.REAL_IP_LOADING + } + + binding.ipscramblingSelectLocation.setSelection(state.selectedLocationPosition) + + // TODO: this should not be mandatory. + binding.apps.post { + (binding.apps.adapter as ToggleAppsAdapter?)?.setData( + list = state.getApps(), + isEnabled = state.mode == InternetPrivacyMode.HIDE_IP + ) + } + + val viewIdsToHide = listOf( + binding.ipscramblingLocationLabel, + binding.selectLocationContainer, + binding.ipscramblingSelectLocation, + binding.ipscramblingSelectApps, + binding.apps + ) + + when { + state.mode in listOf( + InternetPrivacyMode.HIDE_IP_LOADING, + InternetPrivacyMode.REAL_IP_LOADING + ) + || state.availableApps.isEmpty() -> { + binding.loader.visibility = View.VISIBLE + viewIdsToHide.forEach { it.visibility = View.GONE } + } + else -> { + binding.loader.visibility = View.GONE + viewIdsToHide.forEach { it.visibility = View.VISIBLE } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyState.kt b/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyState.kt new file mode 100644 index 0000000..e0df73b --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyState.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.internetprivacy + +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode +import foundation.e.privacymodules.permissions.data.ApplicationDescription + +data class InternetPrivacyState( + val mode: InternetPrivacyMode = InternetPrivacyMode.REAL_IP, + val availableApps: List<ApplicationDescription> = emptyList(), + val bypassTorApps: Collection<String> = emptyList(), + val selectedLocation: String = "", + val availableLocationIds: List<String> = emptyList(), + val forceRedraw: Boolean = false, +) { + fun getApps(): List<Pair<ApplicationDescription, Boolean>> { + return availableApps.map { it to (it.packageName !in bypassTorApps) } + } + + val selectedLocationPosition get() = availableLocationIds.indexOf(selectedLocation) +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyViewModel.kt b/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyViewModel.kt new file mode 100644 index 0000000..051c8e8 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/internetprivacy/InternetPrivacyViewModel.kt @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.internetprivacy + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode +import foundation.e.advancedprivacy.domain.usecases.AppListUseCase +import foundation.e.advancedprivacy.domain.usecases.GetQuickPrivacyStateUseCase +import foundation.e.advancedprivacy.domain.usecases.IpScramblingStateUseCase +import foundation.e.privacymodules.ipscramblermodule.IIpScramblerModule +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class InternetPrivacyViewModel( + private val ipScramblerModule: IIpScramblerModule, + private val getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase, + private val ipScramblingStateUseCase: IpScramblingStateUseCase, + private val appListUseCase: AppListUseCase +) : ViewModel() { + companion object { + private const val WARNING_LOADING_LONG_DELAY = 5 * 1000L + } + + private val _state = MutableStateFlow(InternetPrivacyState()) + val state = _state.asStateFlow() + + private val _singleEvents = MutableSharedFlow<SingleEvent>() + val singleEvents = _singleEvents.asSharedFlow() + + val availablesLocationsIds = listOf("", *ipScramblerModule.getAvailablesLocations().sorted().toTypedArray()) + + init { + viewModelScope.launch(Dispatchers.IO) { + _state.update { + it.copy( + mode = ipScramblingStateUseCase.internetPrivacyMode.value, + availableLocationIds = availablesLocationsIds, + selectedLocation = ipScramblerModule.exitCountry + ) + } + } + } + + @OptIn(FlowPreview::class) + suspend fun doOnStartedState() = withContext(Dispatchers.IO) { + launch { + merge( + appListUseCase.getAppsUsingInternet().map { apps -> + _state.update { s -> + s.copy( + availableApps = apps, + bypassTorApps = ipScramblingStateUseCase.bypassTorApps + ) + } + }, + ipScramblingStateUseCase.internetPrivacyMode.map { + _state.update { s -> s.copy(mode = it) } + } + ).collect {} + } + + launch { + ipScramblingStateUseCase.internetPrivacyMode + .map { it == InternetPrivacyMode.HIDE_IP_LOADING } + .debounce(WARNING_LOADING_LONG_DELAY) + .collect { + if (it) _singleEvents.emit( + SingleEvent.ErrorEvent(R.string.ipscrambling_warning_starting_long) + ) + } + } + + launch { + getQuickPrivacyStateUseCase.otherVpnRunning.collect { + _singleEvents.emit( + SingleEvent.ErrorEvent( + R.string.ipscrambling_error_always_on_vpn_already_running, + listOf(it.label ?: "") + ) + ) + _state.update { it.copy(forceRedraw = !it.forceRedraw) } + } + } + } + + fun submitAction(action: Action) = viewModelScope.launch { + when (action) { + is Action.UseRealIPAction -> actionUseRealIP() + is Action.UseHiddenIPAction -> actionUseHiddenIP() + is Action.ToggleAppIpScrambled -> actionToggleAppIpScrambled(action) + is Action.SelectLocationAction -> actionSelectLocation(action) + } + } + + private fun actionUseRealIP() { + ipScramblingStateUseCase.toggle(hideIp = false) + } + + private fun actionUseHiddenIP() { + ipScramblingStateUseCase.toggle(hideIp = true) + } + + private suspend fun actionToggleAppIpScrambled(action: Action.ToggleAppIpScrambled) = withContext(Dispatchers.IO) { + ipScramblingStateUseCase.toggleBypassTor(action.packageName) + _state.update { it.copy(bypassTorApps = ipScramblingStateUseCase.bypassTorApps) } + } + + private suspend fun actionSelectLocation(action: Action.SelectLocationAction) = withContext(Dispatchers.IO) { + val locationId = _state.value.availableLocationIds[action.position] + if (locationId != ipScramblerModule.exitCountry) { + ipScramblerModule.exitCountry = locationId + _state.update { it.copy(selectedLocation = locationId) } + } + } + + sealed class SingleEvent { + data class ErrorEvent( + @StringRes val errorResId: Int, + val args: List<Any> = emptyList() + ) : SingleEvent() + } + + sealed class Action { + object UseRealIPAction : Action() + object UseHiddenIPAction : Action() + data class ToggleAppIpScrambled(val packageName: String) : Action() + data class SelectLocationAction(val position: Int) : Action() + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationFragment.kt b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationFragment.kt new file mode 100644 index 0000000..089151e --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationFragment.kt @@ -0,0 +1,376 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.location + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.location.Location +import android.os.Bundle +import android.text.Editable +import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.NonNull +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.google.android.material.textfield.TextInputLayout.END_ICON_CUSTOM +import com.google.android.material.textfield.TextInputLayout.END_ICON_NONE +import com.mapbox.mapboxsdk.Mapbox +import com.mapbox.mapboxsdk.camera.CameraUpdateFactory +import com.mapbox.mapboxsdk.geometry.LatLng +import com.mapbox.mapboxsdk.location.LocationComponent +import com.mapbox.mapboxsdk.location.LocationComponentActivationOptions +import com.mapbox.mapboxsdk.location.LocationUpdate +import com.mapbox.mapboxsdk.location.modes.CameraMode +import com.mapbox.mapboxsdk.location.modes.RenderMode +import com.mapbox.mapboxsdk.maps.MapboxMap +import com.mapbox.mapboxsdk.maps.Style +import foundation.e.advancedprivacy.AdvancedPrivacyApplication +import foundation.e.advancedprivacy.DependencyContainer +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.common.NavToolbarFragment +import foundation.e.advancedprivacy.databinding.FragmentFakeLocationBinding +import foundation.e.advancedprivacy.domain.entities.LocationMode +import foundation.e.advancedprivacy.features.location.FakeLocationViewModel.Action +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch + +class FakeLocationFragment : NavToolbarFragment(R.layout.fragment_fake_location) { + + private var isFirstLaunch: Boolean = true + + private val dependencyContainer: DependencyContainer by lazy { + (this.requireActivity().application as AdvancedPrivacyApplication).dependencyContainer + } + + private val viewModel: FakeLocationViewModel by viewModels { + dependencyContainer.viewModelsFactory + } + + private var _binding: FragmentFakeLocationBinding? = null + private val binding get() = _binding!! + + private var mapboxMap: MapboxMap? = null + private var locationComponent: LocationComponent? = null + + private var inputJob: Job? = null + + private val locationPermissionRequest = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + if (permissions.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false) || + permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false) + ) { + viewModel.submitAction(Action.StartListeningLocation) + } // TODO: else. + } + + companion object { + private const val DEBOUNCE_PERIOD = 1000L + } + + override fun onAttach(context: Context) { + super.onAttach(context) + Mapbox.getInstance(requireContext(), getString(R.string.mapbox_key)) + } + + override fun getTitle(): String = getString(R.string.location_title) + + private fun displayToast(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT) + .show() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentFakeLocationBinding.bind(view) + + binding.mapView.setup(savedInstanceState) { mapboxMap -> + this.mapboxMap = mapboxMap + mapboxMap.uiSettings.isRotateGesturesEnabled = false + mapboxMap.setStyle(Style.MAPBOX_STREETS) { style -> + enableLocationPlugin(style) + + mapboxMap.addOnCameraMoveListener { + if (binding.mapView.isEnabled) { + mapboxMap.cameraPosition.target.let { + viewModel.submitAction( + Action.SetSpecificLocationAction( + it.latitude.toFloat(), + it.longitude.toFloat() + ) + ) + } + } + } + // Bind click listeners once map is ready. + bindClickListeners() + + render(viewModel.state.value) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect { event -> + if (event is FakeLocationViewModel.SingleEvent.LocationUpdatedEvent) { + updateLocation(event.location, event.mode) + } + } + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + render(viewModel.state.value) + viewModel.state.collect(::render) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect { event -> + when (event) { + is FakeLocationViewModel.SingleEvent.ErrorEvent -> { + displayToast(event.error) + } + is FakeLocationViewModel.SingleEvent.RequestLocationPermission -> { + // TODO for standalone: rationale dialog + locationPermissionRequest.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + } + is FakeLocationViewModel.SingleEvent.LocationUpdatedEvent -> { + // Nothing here, another collect linked to mapbox view. + } + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } + } + } + + private fun getCoordinatesAfterTextChanged( + inputLayout: TextInputLayout, + editText: TextInputEditText, + isLat: Boolean + ) = { editable: Editable? -> + inputJob?.cancel() + if (editable != null && editable.isNotEmpty() && editText.isEnabled) { + inputJob = lifecycleScope.launch { + delay(DEBOUNCE_PERIOD) + ensureActive() + try { + val value = editable.toString().toFloat() + val maxValue = if (isLat) 90f else 180f + + if (value > maxValue || value < -maxValue) { + throw NumberFormatException("value $value is out of bounds") + } + inputLayout.error = null + + inputLayout.setEndIconDrawable(R.drawable.ic_valid) + inputLayout.endIconMode = END_ICON_CUSTOM + + // Here, value is valid, try to send the values + try { + val lat = binding.edittextLatitude.text.toString().toFloat() + val lon = binding.edittextLongitude.text.toString().toFloat() + if (lat <= 90f && lat >= -90f && lon <= 180f && lon >= -180f) { + mapboxMap?.moveCamera( + CameraUpdateFactory.newLatLng( + LatLng(lat.toDouble(), lon.toDouble()) + ) + ) + } + } catch (e: NumberFormatException) { + } + } catch (e: NumberFormatException) { + inputLayout.endIconMode = END_ICON_NONE + inputLayout.error = getString(R.string.location_input_error) + } + } + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun bindClickListeners() { + binding.radioUseRealLocation.setOnClickListener { + viewModel.submitAction(Action.UseRealLocationAction) + } + binding.radioUseRandomLocation.setOnClickListener { + viewModel.submitAction(Action.UseRandomLocationAction) + } + binding.radioUseSpecificLocation.setOnClickListener { + mapboxMap?.cameraPosition?.target?.let { + viewModel.submitAction( + Action.SetSpecificLocationAction(it.latitude.toFloat(), it.longitude.toFloat()) + ) + } + } + binding.edittextLatitude.addTextChangedListener( + afterTextChanged = getCoordinatesAfterTextChanged( + binding.textlayoutLatitude, + binding.edittextLatitude, + true + ) + ) + + binding.edittextLongitude.addTextChangedListener( + afterTextChanged = getCoordinatesAfterTextChanged( + binding.textlayoutLongitude, + binding.edittextLongitude, + false + ) + ) + } + + @SuppressLint("MissingPermission") + private fun render(state: FakeLocationState) { + binding.radioUseRandomLocation.isChecked = state.mode == LocationMode.RANDOM_LOCATION + + binding.radioUseSpecificLocation.isChecked = state.mode == LocationMode.SPECIFIC_LOCATION + + binding.radioUseRealLocation.isChecked = state.mode == LocationMode.REAL_LOCATION + + binding.mapView.isEnabled = (state.mode == LocationMode.SPECIFIC_LOCATION) + + if (state.mode == LocationMode.REAL_LOCATION) { + binding.centeredMarker.isVisible = false + } else { + binding.mapLoader.isVisible = false + binding.mapOverlay.isVisible = state.mode != LocationMode.SPECIFIC_LOCATION + binding.centeredMarker.isVisible = true + + mapboxMap?.moveCamera( + CameraUpdateFactory.newLatLng( + LatLng( + state.specificLatitude?.toDouble() ?: 0.0, + state.specificLongitude?.toDouble() ?: 0.0 + ) + ) + ) + } + + binding.textlayoutLatitude.isVisible = (state.mode == LocationMode.SPECIFIC_LOCATION) + binding.textlayoutLongitude.isVisible = (state.mode == LocationMode.SPECIFIC_LOCATION) + + binding.edittextLatitude.setText(state.specificLatitude?.toString()) + binding.edittextLongitude.setText(state.specificLongitude?.toString()) + } + + @SuppressLint("MissingPermission") + private fun updateLocation(lastLocation: Location?, mode: LocationMode) { + lastLocation?.let { location -> + locationComponent?.isLocationComponentEnabled = true + val locationUpdate = LocationUpdate.Builder() + .location(location) + .animationDuration(100) + .build() + locationComponent?.forceLocationUpdate(locationUpdate) + + if (mode == LocationMode.REAL_LOCATION) { + binding.mapLoader.isVisible = false + binding.mapOverlay.isVisible = false + + val update = CameraUpdateFactory.newLatLng( + LatLng(location.latitude, location.longitude) + ) + + if (isFirstLaunch) { + mapboxMap?.moveCamera(update) + isFirstLaunch = false + } else { + mapboxMap?.animateCamera(update) + } + } + } ?: run { + locationComponent?.isLocationComponentEnabled = false + if (mode == LocationMode.REAL_LOCATION) { + binding.mapLoader.isVisible = true + binding.mapOverlay.isVisible = true + } + } + } + + @SuppressLint("MissingPermission") + private fun enableLocationPlugin(@NonNull loadedMapStyle: Style) { + // Check if permissions are enabled and if not request + locationComponent = mapboxMap?.locationComponent + locationComponent?.activateLocationComponent( + LocationComponentActivationOptions.builder( + requireContext(), loadedMapStyle + ).useDefaultLocationEngine(false).build() + ) + locationComponent?.isLocationComponentEnabled = true + locationComponent?.cameraMode = CameraMode.NONE + locationComponent?.renderMode = RenderMode.NORMAL + } + + override fun onStart() { + super.onStart() + binding.mapView.onStart() + } + + override fun onResume() { + super.onResume() + viewModel.submitAction(Action.StartListeningLocation) + binding.mapView.onResume() + } + + override fun onPause() { + super.onPause() + viewModel.submitAction(Action.StopListeningLocation) + binding.mapView.onPause() + } + + override fun onStop() { + super.onStop() + binding.mapView.onStop() + } + + override fun onLowMemory() { + super.onLowMemory() + binding.mapView.onLowMemory() + } + + override fun onDestroyView() { + super.onDestroyView() + binding.mapView.onDestroy() + mapboxMap = null + locationComponent = null + inputJob = null + _binding = null + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationMapView.kt b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationMapView.kt new file mode 100644 index 0000000..fbb5b6c --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationMapView.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.location + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Bundle +import android.util.AttributeSet +import android.view.MotionEvent +import com.mapbox.mapboxsdk.maps.MapView +import com.mapbox.mapboxsdk.maps.OnMapReadyCallback + +class FakeLocationMapView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : MapView(context, attrs, defStyleAttr) { + + /** + * Overrides onTouchEvent because this MapView is part of a scroll view + * and we want this map view to consume all touch events originating on this view. + */ + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean { + when (event?.action) { + MotionEvent.ACTION_DOWN -> parent.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_UP -> parent.requestDisallowInterceptTouchEvent(false) + } + super.onTouchEvent(event) + return true + } +} + +fun FakeLocationMapView.setup(savedInstanceState: Bundle?, callback: OnMapReadyCallback) = + this.apply { + onCreate(savedInstanceState) + getMapAsync(callback) + } diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationState.kt b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationState.kt new file mode 100644 index 0000000..baa672b --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.location + +import android.location.Location +import foundation.e.advancedprivacy.domain.entities.LocationMode + +data class FakeLocationState( + val mode: LocationMode = LocationMode.REAL_LOCATION, + val currentLocation: Location? = null, + val specificLatitude: Float? = null, + val specificLongitude: Float? = null, + val forceRefresh: Boolean = false, +) diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationViewModel.kt b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationViewModel.kt new file mode 100644 index 0000000..87b64c5 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/location/FakeLocationViewModel.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.location + +import android.location.Location +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import foundation.e.advancedprivacy.domain.entities.LocationMode +import foundation.e.advancedprivacy.domain.usecases.FakeLocationStateUseCase +import foundation.e.advancedprivacy.domain.usecases.GetQuickPrivacyStateUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds + +class FakeLocationViewModel( + private val getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase, + private val fakeLocationStateUseCase: FakeLocationStateUseCase +) : ViewModel() { + companion object { + private val SET_SPECIFIC_LOCATION_DELAY = 200.milliseconds + } + + private val _state = MutableStateFlow(FakeLocationState()) + val state = _state.asStateFlow() + + private val _singleEvents = MutableSharedFlow<SingleEvent>() + val singleEvents = _singleEvents.asSharedFlow() + + private val specificLocationInputFlow = MutableSharedFlow<Action.SetSpecificLocationAction>() + + @OptIn(FlowPreview::class) + suspend fun doOnStartedState() = withContext(Dispatchers.Main) { + launch { + merge( + fakeLocationStateUseCase.configuredLocationMode.map { (mode, lat, lon) -> + _state.update { s -> + s.copy( + mode = mode, + specificLatitude = lat, + specificLongitude = lon + ) + } + }, + specificLocationInputFlow + .debounce(SET_SPECIFIC_LOCATION_DELAY).map { action -> + fakeLocationStateUseCase.setSpecificLocation(action.latitude, action.longitude) + } + ).collect {} + } + + launch { + fakeLocationStateUseCase.currentLocation.collect { location -> + _singleEvents.emit( + SingleEvent.LocationUpdatedEvent( + mode = _state.value.mode, + location = location + ) + ) + } + } + } + + fun submitAction(action: Action) = viewModelScope.launch { + when (action) { + is Action.StartListeningLocation -> actionStartListeningLocation() + is Action.StopListeningLocation -> fakeLocationStateUseCase.stopListeningLocation() + is Action.SetSpecificLocationAction -> setSpecificLocation(action) + is Action.UseRandomLocationAction -> fakeLocationStateUseCase.setRandomLocation() + is Action.UseRealLocationAction -> + fakeLocationStateUseCase.stopFakeLocation() + } + } + + private suspend fun actionStartListeningLocation() { + val started = fakeLocationStateUseCase.startListeningLocation() + if (!started) { + _singleEvents.emit(SingleEvent.RequestLocationPermission) + } + } + + private suspend fun setSpecificLocation(action: Action.SetSpecificLocationAction) { + specificLocationInputFlow.emit(action) + } + + sealed class SingleEvent { + data class LocationUpdatedEvent(val mode: LocationMode, val location: Location?) : SingleEvent() + object RequestLocationPermission : SingleEvent() + data class ErrorEvent(val error: String) : SingleEvent() + } + + sealed class Action { + object StartListeningLocation : Action() + object StopListeningLocation : Action() + object UseRealLocationAction : Action() + object UseRandomLocationAction : Action() + data class SetSpecificLocationAction( + val latitude: Float, + val longitude: Float + ) : Action() + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersFragment.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersFragment.kt new file mode 100644 index 0000000..3e17334 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersFragment.kt @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2021 E FOUNDATION, 2022 MURENA SAS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.trackers + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.text.Spannable +import android.text.SpannableString +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan +import android.text.style.UnderlineSpan +import android.view.View +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.fragment.app.commit +import androidx.fragment.app.replace +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import foundation.e.advancedprivacy.AdvancedPrivacyApplication +import foundation.e.advancedprivacy.DependencyContainer +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.common.AppsAdapter +import foundation.e.advancedprivacy.common.GraphHolder +import foundation.e.advancedprivacy.common.NavToolbarFragment +import foundation.e.advancedprivacy.common.setToolTipForAsterisk +import foundation.e.advancedprivacy.databinding.FragmentTrackersBinding +import foundation.e.advancedprivacy.databinding.TrackersItemGraphBinding +import foundation.e.advancedprivacy.domain.entities.TrackersPeriodicStatistics +import foundation.e.advancedprivacy.features.trackers.apptrackers.AppTrackersFragment +import kotlinx.coroutines.launch + +class TrackersFragment : + NavToolbarFragment(R.layout.fragment_trackers) { + + private val dependencyContainer: DependencyContainer by lazy { + (this.requireActivity().application as AdvancedPrivacyApplication).dependencyContainer + } + + private val viewModel: TrackersViewModel by viewModels { dependencyContainer.viewModelsFactory } + + private var _binding: FragmentTrackersBinding? = null + private val binding get() = _binding!! + + private var dayGraphHolder: GraphHolder? = null + private var monthGraphHolder: GraphHolder? = null + private var yearGraphHolder: GraphHolder? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + _binding = FragmentTrackersBinding.bind(view) + + dayGraphHolder = GraphHolder(binding.graphDay.graph, requireContext(), false) + monthGraphHolder = GraphHolder(binding.graphMonth.graph, requireContext(), false) + yearGraphHolder = GraphHolder(binding.graphYear.graph, requireContext(), false) + + binding.apps.apply { + layoutManager = LinearLayoutManager(requireContext()) + setHasFixedSize(true) + adapter = AppsAdapter(R.layout.trackers_item_app) { appUid -> + viewModel.submitAction( + TrackersViewModel.Action.ClickAppAction(appUid) + ) + } + } + + val infoText = getString(R.string.trackers_info) + val moreText = getString(R.string.trackers_info_more) + + val spannable = SpannableString("$infoText $moreText") + val startIndex = infoText.length + 1 + val endIndex = spannable.length + spannable.setSpan( + ForegroundColorSpan(ContextCompat.getColor(requireContext(), R.color.accent)), + startIndex, + endIndex, + Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + spannable.setSpan(UnderlineSpan(), startIndex, endIndex, Spannable.SPAN_INCLUSIVE_EXCLUSIVE) + spannable.setSpan( + object : ClickableSpan() { + override fun onClick(p0: View) { + viewModel.submitAction(TrackersViewModel.Action.ClickLearnMore) + } + }, + startIndex, endIndex, Spannable.SPAN_INCLUSIVE_EXCLUSIVE + ) + + with(binding.trackersInfo) { + linksClickable = true + isClickable = true + movementMethod = LinkMovementMethod.getInstance() + text = spannable + } + + setToolTipForAsterisk( + textView = binding.trackersAppsListTitle, + textId = R.string.trackers_applist_title, + tooltipTextId = R.string.trackers_applist_infos + ) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + render(viewModel.state.value) + viewModel.state.collect(::render) + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect { event -> + when (event) { + is TrackersViewModel.SingleEvent.ErrorEvent -> { + displayToast(event.error) + } + is TrackersViewModel.SingleEvent.OpenAppDetailsEvent -> { + requireActivity().supportFragmentManager.commit { + replace<AppTrackersFragment>( + R.id.container, + args = AppTrackersFragment.buildArgs( + event.appDesc.label.toString(), + event.appDesc.packageName, + event.appDesc.uid + ) + ) + setReorderingAllowed(true) + addToBackStack("apptrackers") + } + } + is TrackersViewModel.SingleEvent.OpenUrl -> { + try { + startActivity(Intent(Intent.ACTION_VIEW, event.url)) + } catch (e: ActivityNotFoundException) { + Toast.makeText( + requireContext(), + R.string.error_no_activity_view_url, + Toast.LENGTH_SHORT + ).show() + } + } + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } + } + } + + private fun displayToast(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT) + .show() + } + + override fun getTitle() = getString(R.string.trackers_title) + + private fun render(state: TrackersState) { + state.dayStatistics?.let { renderGraph(it, dayGraphHolder!!, binding.graphDay) } + state.monthStatistics?.let { renderGraph(it, monthGraphHolder!!, binding.graphMonth) } + state.yearStatistics?.let { renderGraph(it, yearGraphHolder!!, binding.graphYear) } + + state.apps?.let { + binding.apps.post { + (binding.apps.adapter as AppsAdapter?)?.dataSet = it + } + } + } + + private fun renderGraph( + statistics: TrackersPeriodicStatistics, + graphHolder: GraphHolder, + graphBinding: TrackersItemGraphBinding + ) { + if (statistics.callsBlockedNLeaked.all { it.first == 0 && it.second == 0 }) { + graphBinding.graph.visibility = View.INVISIBLE + graphBinding.graphEmpty.isVisible = true + } else { + graphBinding.graph.isVisible = true + graphBinding.graphEmpty.isVisible = false + graphHolder.data = statistics.callsBlockedNLeaked + graphHolder.labels = statistics.periods + graphBinding.trackersCountLabel.text = + getString(R.string.trackers_count_label, statistics.trackersCount) + } + } + + override fun onDestroyView() { + super.onDestroyView() + dayGraphHolder = null + monthGraphHolder = null + yearGraphHolder = null + _binding = null + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersState.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersState.kt new file mode 100644 index 0000000..13719e4 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersState.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.trackers + +import foundation.e.advancedprivacy.domain.entities.AppWithCounts +import foundation.e.advancedprivacy.domain.entities.TrackersPeriodicStatistics + +data class TrackersState( + val dayStatistics: TrackersPeriodicStatistics? = null, + val monthStatistics: TrackersPeriodicStatistics? = null, + val yearStatistics: TrackersPeriodicStatistics? = null, + val apps: List<AppWithCounts>? = null, +) diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersViewModel.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersViewModel.kt new file mode 100644 index 0000000..bcb4df8 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/TrackersViewModel.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2021 E FOUNDATION, 2022 MURENA SAS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.trackers + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import foundation.e.advancedprivacy.domain.entities.AppWithCounts +import foundation.e.advancedprivacy.domain.usecases.TrackersStatisticsUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class TrackersViewModel( + private val trackersStatisticsUseCase: TrackersStatisticsUseCase +) : ViewModel() { + + companion object { + private const val URL_LEARN_MORE_ABOUT_TRACKERS = + "https://doc.e.foundation/support-topics/advanced_privacy#trackers-blocker" + } + + private val _state = MutableStateFlow(TrackersState()) + val state = _state.asStateFlow() + + private val _singleEvents = MutableSharedFlow<SingleEvent>() + val singleEvents = _singleEvents.asSharedFlow() + + suspend fun doOnStartedState() = withContext(Dispatchers.IO) { + merge( + trackersStatisticsUseCase.listenUpdates().map { + trackersStatisticsUseCase.getDayMonthYearStatistics() + .let { (day, month, year) -> + _state.update { s -> + s.copy( + dayStatistics = day, + monthStatistics = month, + yearStatistics = year + ) + } + } + }, + trackersStatisticsUseCase.getAppsWithCounts().map { + _state.update { s -> s.copy(apps = it) } + } + ).collect {} + } + + fun submitAction(action: Action) = viewModelScope.launch { + when (action) { + is Action.ClickAppAction -> actionClickApp(action) + is Action.ClickLearnMore -> + _singleEvents.emit(SingleEvent.OpenUrl(Uri.parse(URL_LEARN_MORE_ABOUT_TRACKERS))) + } + } + + private suspend fun actionClickApp(action: Action.ClickAppAction) { + state.value.apps?.find { it.uid == action.appUid }?.let { + _singleEvents.emit(SingleEvent.OpenAppDetailsEvent(it)) + } + } + + sealed class SingleEvent { + data class ErrorEvent(val error: String) : SingleEvent() + data class OpenAppDetailsEvent(val appDesc: AppWithCounts) : SingleEvent() + data class OpenUrl(val url: Uri) : SingleEvent() + } + + sealed class Action { + data class ClickAppAction(val appUid: Int) : Action() + object ClickLearnMore : Action() + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersFragment.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersFragment.kt new file mode 100644 index 0000000..2bb53d6 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersFragment.kt @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2023 MURENA SAS + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.trackers.apptrackers + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.core.os.bundleOf +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.snackbar.Snackbar +import foundation.e.advancedprivacy.AdvancedPrivacyApplication +import foundation.e.advancedprivacy.DependencyContainer +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.common.NavToolbarFragment +import foundation.e.advancedprivacy.databinding.ApptrackersFragmentBinding +import kotlinx.coroutines.launch + +class AppTrackersFragment : NavToolbarFragment(R.layout.apptrackers_fragment) { + companion object { + private val PARAM_LABEL = "PARAM_LABEL" + private val PARAM_PACKAGE_NAME = "PARAM_PACKAGE_NAME" + + const val PARAM_APP_UID = "PARAM_APP_UID" + + fun buildArgs(label: String, packageName: String, appUid: Int): Bundle = bundleOf( + PARAM_LABEL to label, + PARAM_PACKAGE_NAME to packageName, + PARAM_APP_UID to appUid + ) + } + + private val dependencyContainer: DependencyContainer by lazy { + (this.requireActivity().application as AdvancedPrivacyApplication).dependencyContainer + } + + private val viewModel: AppTrackersViewModel by viewModels { + dependencyContainer.viewModelsFactory + } + + private var _binding: ApptrackersFragmentBinding? = null + private val binding get() = _binding!! + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (arguments == null || + requireArguments().getInt(PARAM_APP_UID, Int.MIN_VALUE) == Int.MIN_VALUE + ) { + activity?.supportFragmentManager?.popBackStack() + } + } + + private fun displayToast(message: String) { + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT) + .show() + } + + override fun getTitle(): String = requireArguments().getString(PARAM_LABEL) ?: "" + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = ApptrackersFragmentBinding.bind(view) + + binding.blockAllToggle.setOnClickListener { + viewModel.submitAction(AppTrackersViewModel.Action.BlockAllToggleAction(binding.blockAllToggle.isChecked)) + } + binding.btnReset.setOnClickListener { + viewModel.submitAction(AppTrackersViewModel.Action.ResetAllTrackers) + } + + binding.trackers.apply { + layoutManager = LinearLayoutManager(requireContext()) + setHasFixedSize(true) + adapter = ToggleTrackersAdapter( + R.layout.apptrackers_item_tracker_toggle, + onToggleSwitch = { tracker, isBlocked -> + viewModel.submitAction(AppTrackersViewModel.Action.ToggleTrackerAction(tracker, isBlocked)) + }, + onClickTitle = { viewModel.submitAction(AppTrackersViewModel.Action.ClickTracker(it)) }, + ) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.singleEvents.collect { event -> + when (event) { + is AppTrackersViewModel.SingleEvent.ErrorEvent -> + displayToast(getString(event.errorResId)) + is AppTrackersViewModel.SingleEvent.OpenUrl -> + try { + startActivity(Intent(Intent.ACTION_VIEW, event.url)) + } catch (e: ActivityNotFoundException) { + Toast.makeText( + requireContext(), + R.string.error_no_activity_view_url, + Toast.LENGTH_SHORT + ).show() + } + is AppTrackersViewModel.SingleEvent.ToastTrackersControlDisabled -> + Snackbar.make( + binding.root, + R.string.apptrackers_tracker_control_disabled_message, + Snackbar.LENGTH_LONG + ).show() + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.doOnStartedState() + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + render(viewModel.state.value) + viewModel.state.collect(::render) + } + } + } + + private fun render(state: AppTrackersState) { + binding.trackersCountSummary.text = if (state.getTrackersCount() == 0) "" + else getString( + R.string.apptrackers_trackers_count_summary, + state.getBlockedTrackersCount(), + state.getTrackersCount(), + state.blocked, + state.leaked + ) + + binding.blockAllToggle.isChecked = state.isBlockingActivated + + val trackersStatus = state.getTrackersStatus() + if (!trackersStatus.isNullOrEmpty()) { + binding.trackersListTitle.isVisible = state.isBlockingActivated + binding.trackers.isVisible = true + binding.trackers.post { + (binding.trackers.adapter as ToggleTrackersAdapter?)?.updateDataSet( + trackersStatus, + state.isBlockingActivated + ) + } + binding.noTrackersYet.isVisible = false + binding.btnReset.isVisible = true + } else { + binding.trackersListTitle.isVisible = false + binding.trackers.isVisible = false + binding.noTrackersYet.isVisible = true + binding.noTrackersYet.text = getString( + when { + !state.isBlockingActivated -> R.string.apptrackers_no_trackers_yet_block_off + state.isWhitelistEmpty -> R.string.apptrackers_no_trackers_yet_block_on + else -> R.string.app_trackers_no_trackers_yet_remaining_whitelist + } + ) + binding.btnReset.isVisible = state.isBlockingActivated && !state.isWhitelistEmpty + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersState.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersState.kt new file mode 100644 index 0000000..2a9e6e8 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersState.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2023 MURENA SAS + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.trackers.apptrackers + +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import foundation.e.privacymodules.trackers.api.Tracker + +data class AppTrackersState( + val appDesc: ApplicationDescription? = null, + val isBlockingActivated: Boolean = false, + val trackersWithWhiteList: List<Pair<Tracker, Boolean>>? = null, + val leaked: Int = 0, + val blocked: Int = 0, + val isTrackersBlockingEnabled: Boolean = false, + val isWhitelistEmpty: Boolean = true, + val showQuickPrivacyDisabledMessage: Boolean = false, +) { + fun getTrackersStatus(): List<Pair<Tracker, Boolean>>? { + return trackersWithWhiteList?.map { it.first to !it.second } + } + + fun getTrackersCount() = trackersWithWhiteList?.size ?: 0 + fun getBlockedTrackersCount(): Int = if (isTrackersBlockingEnabled && isBlockingActivated) + trackersWithWhiteList?.count { !it.second } ?: 0 + else 0 +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersViewModel.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersViewModel.kt new file mode 100644 index 0000000..cda4b4b --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/AppTrackersViewModel.kt @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2023 MURENA SAS + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.trackers.apptrackers + +import android.net.Uri +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import foundation.e.advancedprivacy.domain.entities.TrackerMode +import foundation.e.advancedprivacy.domain.usecases.GetQuickPrivacyStateUseCase +import foundation.e.advancedprivacy.domain.usecases.TrackersStateUseCase +import foundation.e.advancedprivacy.domain.usecases.TrackersStatisticsUseCase +import foundation.e.privacymodules.permissions.data.ApplicationDescription +import foundation.e.privacymodules.trackers.api.Tracker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class AppTrackersViewModel( + private val app: ApplicationDescription, + private val trackersStateUseCase: TrackersStateUseCase, + private val trackersStatisticsUseCase: TrackersStatisticsUseCase, + private val getQuickPrivacyStateUseCase: GetQuickPrivacyStateUseCase +) : ViewModel() { + companion object { + private const val exodusBaseUrl = "https://reports.exodus-privacy.eu.org/trackers/" + } + + private val _state = MutableStateFlow(AppTrackersState()) + val state = _state.asStateFlow() + + private val _singleEvents = MutableSharedFlow<SingleEvent>() + val singleEvents = _singleEvents.asSharedFlow() + + init { + viewModelScope.launch(Dispatchers.IO) { + _state.update { + it.copy( + appDesc = app, + isBlockingActivated = !trackersStateUseCase.isWhitelisted(app), + trackersWithWhiteList = trackersStatisticsUseCase.getTrackersWithWhiteList( + app + ), + isWhitelistEmpty = trackersStatisticsUseCase.isWhiteListEmpty(app) + ) + } + } + } + + suspend fun doOnStartedState() = withContext(Dispatchers.IO) { + merge( + getQuickPrivacyStateUseCase.trackerMode.map { + _state.update { s -> s.copy(isTrackersBlockingEnabled = it != TrackerMode.VULNERABLE) } + }, + trackersStatisticsUseCase.listenUpdates().map { fetchStatistics() } + ).collect { } + } + + fun submitAction(action: Action) = viewModelScope.launch { + when (action) { + is Action.BlockAllToggleAction -> blockAllToggleAction(action) + is Action.ToggleTrackerAction -> toggleTrackerAction(action) + is Action.ClickTracker -> actionClickTracker(action) + is Action.ResetAllTrackers -> resetAllTrackers() + } + } + + private suspend fun blockAllToggleAction(action: Action.BlockAllToggleAction) { + withContext(Dispatchers.IO) { + if (!state.value.isTrackersBlockingEnabled) { + _singleEvents.emit(SingleEvent.ToastTrackersControlDisabled) + } + trackersStateUseCase.toggleAppWhitelist(app, !action.isBlocked) + _state.update { + it.copy( + isBlockingActivated = !trackersStateUseCase.isWhitelisted(app) + ) + } + } + } + + private suspend fun toggleTrackerAction(action: Action.ToggleTrackerAction) { + withContext(Dispatchers.IO) { + if (!state.value.isTrackersBlockingEnabled) { + _singleEvents.emit(SingleEvent.ToastTrackersControlDisabled) + } + + if (state.value.isBlockingActivated) { + trackersStateUseCase.blockTracker(app, action.tracker, action.isBlocked) + updateWhitelist() + } + } + } + + private suspend fun actionClickTracker(action: Action.ClickTracker) { + withContext(Dispatchers.IO) { + action.tracker.exodusId?.let { + try { + _singleEvents.emit( + SingleEvent.OpenUrl( + Uri.parse(exodusBaseUrl + it) + ) + ) + } catch (e: Exception) { + } + } + } + } + + private suspend fun resetAllTrackers() { + withContext(Dispatchers.IO) { + trackersStateUseCase.clearWhitelist(app) + updateWhitelist() + } + } + private fun fetchStatistics() { + val (blocked, leaked) = trackersStatisticsUseCase.getCalls(app) + return _state.update { s -> + s.copy( + trackersWithWhiteList = trackersStatisticsUseCase.getTrackersWithWhiteList(app), + leaked = leaked, + blocked = blocked, + isWhitelistEmpty = trackersStatisticsUseCase.isWhiteListEmpty(app) + ) + } + } + + private fun updateWhitelist() { + _state.update { s -> + s.copy( + trackersWithWhiteList = trackersStatisticsUseCase.getTrackersWithWhiteList(app), + isWhitelistEmpty = trackersStatisticsUseCase.isWhiteListEmpty(app) + ) + } + } + + sealed class SingleEvent { + data class ErrorEvent(@StringRes val errorResId: Int) : SingleEvent() + data class OpenUrl(val url: Uri) : SingleEvent() + object ToastTrackersControlDisabled : SingleEvent() + } + + sealed class Action { + data class BlockAllToggleAction(val isBlocked: Boolean) : Action() + data class ToggleTrackerAction(val tracker: Tracker, val isBlocked: Boolean) : Action() + data class ClickTracker(val tracker: Tracker) : Action() + object ResetAllTrackers : Action() + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/ToggleTrackersAdapter.kt b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/ToggleTrackersAdapter.kt new file mode 100644 index 0000000..3696939 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/features/trackers/apptrackers/ToggleTrackersAdapter.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.features.trackers.apptrackers + +import android.text.SpannableString +import android.text.style.UnderlineSpan +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Switch +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import foundation.e.advancedprivacy.R +import foundation.e.privacymodules.trackers.api.Tracker + +class ToggleTrackersAdapter( + private val itemsLayout: Int, + private val onToggleSwitch: (Tracker, Boolean) -> Unit, + private val onClickTitle: (Tracker) -> Unit +) : RecyclerView.Adapter<ToggleTrackersAdapter.ViewHolder>() { + + var isEnabled = true + + class ViewHolder( + view: View, + private val onToggleSwitch: (Tracker, Boolean) -> Unit, + private val onClickTitle: (Tracker) -> Unit + ) : RecyclerView.ViewHolder(view) { + val title: TextView = view.findViewById(R.id.title) + + val toggle: Switch = view.findViewById(R.id.toggle) + + fun bind(item: Pair<Tracker, Boolean>, isEnabled: Boolean) { + val text = item.first.label + if (item.first.exodusId != null) { + title.setTextColor(ContextCompat.getColor(title.context, R.color.accent)) + val spannable = SpannableString(text) + spannable.setSpan(UnderlineSpan(), 0, spannable.length, 0) + title.text = spannable + } else { + title.setTextColor(ContextCompat.getColor(title.context, R.color.primary_text)) + title.text = text + } + + toggle.isChecked = item.second + toggle.isEnabled = isEnabled + + toggle.setOnClickListener { + onToggleSwitch(item.first, toggle.isChecked) + } + + title.setOnClickListener { onClickTitle(item.first) } + } + } + + private var dataSet: List<Pair<Tracker, Boolean>> = emptyList() + + fun updateDataSet(new: List<Pair<Tracker, Boolean>>, isEnabled: Boolean) { + this.isEnabled = isEnabled + dataSet = new + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(itemsLayout, parent, false) + return ViewHolder(view, onToggleSwitch, onClickTitle) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val permission = dataSet[position] + holder.bind(permission, isEnabled) + } + + override fun getItemCount(): Int = dataSet.size +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/main/MainActivity.kt b/app/src/main/java/foundation/e/advancedprivacy/main/MainActivity.kt new file mode 100644 index 0000000..ec33e25 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/main/MainActivity.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2021 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.main + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.add +import androidx.fragment.app.commit +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.features.dashboard.DashboardFragment +import foundation.e.advancedprivacy.features.internetprivacy.InternetPrivacyFragment +import foundation.e.advancedprivacy.features.location.FakeLocationFragment +import foundation.e.advancedprivacy.features.trackers.TrackersFragment + +open class MainActivity : FragmentActivity(R.layout.activity_main) { + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + handleIntent(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleIntent(intent) + } + + open fun handleIntent(intent: Intent) { + supportFragmentManager.commit { + setReorderingAllowed(true) + when (intent.action) { + ACTION_HIGHLIGHT_LEAKS -> add<DashboardFragment>( + containerViewId = R.id.container, + args = intent.extras + ) + ACTION_VIEW_TRACKERS -> { + add<TrackersFragment>(R.id.container) + } + ACTION_VIEW_FAKE_LOCATION -> { + add<FakeLocationFragment>(R.id.container) + } + ACTION_VIEW_IPSCRAMBLING -> { + add<InternetPrivacyFragment>(R.id.container) + } + else -> add<DashboardFragment>(R.id.container) + } + disallowAddToBackStack() + } + } + + override fun finishAfterTransition() { + val resultData = Intent() + val result = onPopulateResultIntent(resultData) + setResult(result, resultData) + + super.finishAfterTransition() + } + + open fun onPopulateResultIntent(intent: Intent): Int = Activity.RESULT_OK + + companion object { + private const val ACTION_HIGHLIGHT_LEAKS = "ACTION_HIGHLIGHT_LEAKS" + private const val ACTION_VIEW_TRACKERS = "ACTION_VIEW_TRACKERS" + private const val ACTION_VIEW_FAKE_LOCATION = "ACTION_VIEW_FAKE_LOCATION" + private const val ACTION_VIEW_IPSCRAMBLING = "ACTION_VIEW_IPSCRAMBLING" + + fun createHighlightLeaksIntent(context: Context, highlightIndex: Int) = + Intent(context, MainActivity::class.java).apply { + action = ACTION_HIGHLIGHT_LEAKS + putExtras(DashboardFragment.buildArgs(highlightIndex)) + } + + fun createTrackersIntent(context: Context) = + Intent(context, MainActivity::class.java).apply { + action = ACTION_VIEW_TRACKERS + } + + fun createFakeLocationIntent(context: Context): Intent { + return Intent(context, MainActivity::class.java).apply { + action = ACTION_VIEW_FAKE_LOCATION + } + } + + fun createIpScramblingIntent(context: Context): Intent { + return Intent(context, MainActivity::class.java).apply { + action = ACTION_VIEW_IPSCRAMBLING + } + } + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/widget/Widget.kt b/app/src/main/java/foundation/e/advancedprivacy/widget/Widget.kt new file mode 100644 index 0000000..a4272e2 --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/widget/Widget.kt @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.os.Bundle +import foundation.e.advancedprivacy.domain.usecases.GetQuickPrivacyStateUseCase +import foundation.e.advancedprivacy.domain.usecases.TrackersStatisticsUseCase +import foundation.e.advancedprivacy.widget.State +import foundation.e.advancedprivacy.widget.render +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.temporal.ChronoUnit + +/** + * Implementation of App Widget functionality. + */ +class Widget : AppWidgetProvider() { + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + render(context, state.value, appWidgetManager) + } + + override fun onEnabled(context: Context) { + // Enter relevant functionality for when the first widget is created + } + + override fun onDisabled(context: Context) { + // Enter relevant functionality for when the last widget is disabled + } + + companion object { + private var updateWidgetJob: Job? = null + + private var state: StateFlow<State> = MutableStateFlow(State()) + + private const val DARK_TEXT_KEY = "foundation.e.blisslauncher.WIDGET_OPTION_DARK_TEXT" + var isDarkText = false + + @OptIn(FlowPreview::class) + private fun initState( + getPrivacyStateUseCase: GetQuickPrivacyStateUseCase, + trackersStatisticsUseCase: TrackersStatisticsUseCase, + coroutineScope: CoroutineScope + ): StateFlow<State> { + + return combine( + getPrivacyStateUseCase.quickPrivacyState, + getPrivacyStateUseCase.trackerMode, + getPrivacyStateUseCase.isLocationHidden, + getPrivacyStateUseCase.ipScramblingMode, + ) { quickPrivacyState, trackerMode, isLocationHidden, ipScramblingMode -> + + State( + quickPrivacyState = quickPrivacyState, + trackerMode = trackerMode, + isLocationHidden = isLocationHidden, + ipScramblingMode = ipScramblingMode + ) + }.sample(50) + .combine( + merge( + trackersStatisticsUseCase.listenUpdates() + .onStart { emit(Unit) } + .debounce(5000), + flow { + while (true) { + emit(Unit) + delay(ChronoUnit.HOURS.duration.toMillis()) + } + } + + ) + ) { state, _ -> + state.copy( + dayStatistics = trackersStatisticsUseCase.getDayTrackersCalls(), + activeTrackersCount = trackersStatisticsUseCase.getDayTrackersCount() + ) + }.stateIn( + scope = coroutineScope, + started = SharingStarted.Eagerly, + initialValue = State() + ) + } + + @OptIn(DelicateCoroutinesApi::class) + fun startListening( + appContext: Context, + getPrivacyStateUseCase: GetQuickPrivacyStateUseCase, + trackersStatisticsUseCase: TrackersStatisticsUseCase, + ) { + state = initState( + getPrivacyStateUseCase, + trackersStatisticsUseCase, + GlobalScope + ) + + updateWidgetJob?.cancel() + updateWidgetJob = GlobalScope.launch(Dispatchers.Main) { + state.collect { + render(appContext, it, AppWidgetManager.getInstance(appContext)) + } + } + } + } + + override fun onAppWidgetOptionsChanged( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + newOptions: Bundle? + ) { + super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions) + if (newOptions != null) { + isDarkText = newOptions.getBoolean(DARK_TEXT_KEY) + } + render(context, state.value, appWidgetManager) + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/widget/WidgetCommandReceiver.kt b/app/src/main/java/foundation/e/advancedprivacy/widget/WidgetCommandReceiver.kt new file mode 100644 index 0000000..f68a59c --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/widget/WidgetCommandReceiver.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.widget + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import foundation.e.advancedprivacy.AdvancedPrivacyApplication + +class WidgetCommandReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val getQuickPrivacyStateUseCase = (context?.applicationContext as? AdvancedPrivacyApplication)?.dependencyContainer?.getQuickPrivacyStateUseCase + + when (intent?.action) { + ACTION_TOGGLE_TRACKERS -> getQuickPrivacyStateUseCase?.toggleTrackers() + ACTION_TOGGLE_LOCATION -> getQuickPrivacyStateUseCase?.toggleLocation() + ACTION_TOGGLE_IPSCRAMBLING -> getQuickPrivacyStateUseCase?.toggleIpScrambling() + else -> {} + } + } + + companion object { + const val ACTION_TOGGLE_TRACKERS = "toggle_trackers" + const val ACTION_TOGGLE_LOCATION = "toggle_location" + const val ACTION_TOGGLE_IPSCRAMBLING = "toggle_ipscrambling" + } +} diff --git a/app/src/main/java/foundation/e/advancedprivacy/widget/WidgetUI.kt b/app/src/main/java/foundation/e/advancedprivacy/widget/WidgetUI.kt new file mode 100644 index 0000000..cb7fe5c --- /dev/null +++ b/app/src/main/java/foundation/e/advancedprivacy/widget/WidgetUI.kt @@ -0,0 +1,381 @@ +/* + * Copyright (C) 2022 E FOUNDATION + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +package foundation.e.advancedprivacy.widget + +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_IMMUTABLE +import android.app.PendingIntent.FLAG_UPDATE_CURRENT +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.view.View +import android.widget.RemoteViews +import foundation.e.advancedprivacy.R +import foundation.e.advancedprivacy.Widget +import foundation.e.advancedprivacy.Widget.Companion.isDarkText +import foundation.e.advancedprivacy.common.extensions.dpToPxF +import foundation.e.advancedprivacy.domain.entities.InternetPrivacyMode +import foundation.e.advancedprivacy.domain.entities.QuickPrivacyState +import foundation.e.advancedprivacy.domain.entities.TrackerMode +import foundation.e.advancedprivacy.main.MainActivity +import foundation.e.advancedprivacy.widget.WidgetCommandReceiver.Companion.ACTION_TOGGLE_IPSCRAMBLING +import foundation.e.advancedprivacy.widget.WidgetCommandReceiver.Companion.ACTION_TOGGLE_LOCATION +import foundation.e.advancedprivacy.widget.WidgetCommandReceiver.Companion.ACTION_TOGGLE_TRACKERS + +data class State( + val quickPrivacyState: QuickPrivacyState = QuickPrivacyState.DISABLED, + val trackerMode: TrackerMode = TrackerMode.VULNERABLE, + val isLocationHidden: Boolean = false, + val ipScramblingMode: InternetPrivacyMode = InternetPrivacyMode.REAL_IP_LOADING, + val dayStatistics: List<Pair<Int, Int>> = emptyList(), + val activeTrackersCount: Int = 0, +) + +fun render( + context: Context, + state: State, + appWidgetManager: AppWidgetManager, +) { + val views = RemoteViews(context.packageName, R.layout.widget) + applyDarkText(context, state, views) + views.apply { + val openPIntent = PendingIntent.getActivity( + context, + REQUEST_CODE_DASHBOARD, + Intent(context, MainActivity::class.java), + FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT + ) + setOnClickPendingIntent(R.id.settings_btn, openPIntent) + setOnClickPendingIntent(R.id.widget_container, openPIntent) + + setTextViewText( + R.id.state_label, + context.getString( + when (state.quickPrivacyState) { + QuickPrivacyState.DISABLED -> R.string.widget_state_title_off + QuickPrivacyState.FULL_ENABLED -> R.string.widget_state_title_on + QuickPrivacyState.ENABLED -> R.string.widget_state_title_custom + } + ) + ) + + setImageViewResource( + R.id.toggle_trackers, + if (state.trackerMode == TrackerMode.VULNERABLE) + R.drawable.ic_switch_disabled + else R.drawable.ic_switch_enabled + ) + + setOnClickPendingIntent( + R.id.toggle_trackers, + PendingIntent.getBroadcast( + context, + REQUEST_CODE_TOGGLE_TRACKERS, + Intent(context, WidgetCommandReceiver::class.java).apply { + action = ACTION_TOGGLE_TRACKERS + }, + FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT + ) + ) + + setTextViewText( + R.id.state_trackers, + context.getString( + when (state.trackerMode) { + TrackerMode.DENIED -> R.string.widget_state_trackers_on + TrackerMode.VULNERABLE -> R.string.widget_state_trackers_off + TrackerMode.CUSTOM -> R.string.widget_state_trackers_custom + } + ) + ) + + setImageViewResource( + R.id.toggle_location, + if (state.isLocationHidden) R.drawable.ic_switch_enabled + else R.drawable.ic_switch_disabled + ) + + setOnClickPendingIntent( + R.id.toggle_location, + PendingIntent.getBroadcast( + context, + REQUEST_CODE_TOGGLE_LOCATION, + Intent(context, WidgetCommandReceiver::class.java).apply { + action = ACTION_TOGGLE_LOCATION + }, + FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT + ) + ) + + setTextViewText( + R.id.state_geolocation, + context.getString( + if (state.isLocationHidden) R.string.widget_state_geolocation_on + else R.string.widget_state_geolocation_off + ) + ) + + setImageViewResource( + R.id.toggle_ipscrambling, + if (state.ipScramblingMode.isChecked) R.drawable.ic_switch_enabled + else R.drawable.ic_switch_disabled + ) + + setOnClickPendingIntent( + R.id.toggle_ipscrambling, + PendingIntent.getBroadcast( + context, + REQUEST_CODE_TOGGLE_IPSCRAMBLING, + Intent(context, WidgetCommandReceiver::class.java).apply { + action = ACTION_TOGGLE_IPSCRAMBLING + }, + FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT + ) + ) + + setTextViewText( + R.id.state_ip_address, + context.getString( + if (state.ipScramblingMode == InternetPrivacyMode.HIDE_IP) R.string.widget_state_ipaddress_on + else R.string.widget_state_ipaddress_off + ) + ) + + val loading = state.ipScramblingMode.isLoading + + setViewVisibility(R.id.state_ip_address, if (loading) View.GONE else View.VISIBLE) + + setViewVisibility(R.id.state_ip_address_loader, if (loading) View.VISIBLE else View.GONE) + + if (state.dayStatistics.all { it.first == 0 && it.second == 0 }) { + setViewVisibility(R.id.graph, View.GONE) + setViewVisibility(R.id.graph_legend, View.GONE) + setViewVisibility(R.id.graph_empty, View.VISIBLE) + setViewVisibility(R.id.graph_legend_values, View.GONE) + setViewVisibility(R.id.graph_view_trackers_btn, View.GONE) + } else { + setViewVisibility(R.id.graph, View.VISIBLE) + setViewVisibility(R.id.graph_legend, View.VISIBLE) + setViewVisibility(R.id.graph_empty, View.GONE) + setViewVisibility(R.id.graph_legend_values, View.VISIBLE) + setViewVisibility(R.id.graph_view_trackers_btn, View.VISIBLE) + + val pIntent = PendingIntent.getActivity( + context, + REQUEST_CODE_TRACKERS, + MainActivity.createTrackersIntent(context), + FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT + ) + + setOnClickPendingIntent(R.id.graph_view_trackers_btn, pIntent) + + val graphHeightPx = 26.dpToPxF(context) + val maxValue = + state.dayStatistics + .map { it.first + it.second } + .maxOrNull() + .let { if (it == null || it == 0) 1 else it } + val ratio = graphHeightPx / maxValue + + state.dayStatistics.forEachIndexed { index, (blocked, leaked) -> + // blocked (the bar below) + val middlePadding = graphHeightPx - blocked * ratio + setViewPadding(blockedBarIds[index], 0, middlePadding.toInt(), 0, 0) + + // leaked (the bar above) + val topPadding = graphHeightPx - (blocked + leaked) * ratio + setViewPadding(leakedBarIds[index], 0, topPadding.toInt(), 0, 0) + + val highlightPIntent = PendingIntent.getActivity( + context, REQUEST_CODE_HIGHLIGHT + index, + MainActivity.createHighlightLeaksIntent(context, index), + FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT + ) + setOnClickPendingIntent(containerBarIds[index], highlightPIntent) + } + + setTextViewText( + R.id.graph_legend, + context.getString( + R.string.widget_graph_trackers_legend, + state.activeTrackersCount.toString() + ) + ) + } + } + + appWidgetManager.updateAppWidget(ComponentName(context, Widget::class.java), views) +} + +private val containerBarIds = listOf( + R.id.widget_graph_bar_container_0, + R.id.widget_graph_bar_container_1, + R.id.widget_graph_bar_container_2, + R.id.widget_graph_bar_container_3, + R.id.widget_graph_bar_container_4, + R.id.widget_graph_bar_container_5, + R.id.widget_graph_bar_container_6, + R.id.widget_graph_bar_container_7, + R.id.widget_graph_bar_container_8, + R.id.widget_graph_bar_container_9, + R.id.widget_graph_bar_container_10, + R.id.widget_graph_bar_container_11, + R.id.widget_graph_bar_container_12, + R.id.widget_graph_bar_container_13, + R.id.widget_graph_bar_container_14, + R.id.widget_graph_bar_container_15, + R.id.widget_graph_bar_container_16, + R.id.widget_graph_bar_container_17, + R.id.widget_graph_bar_container_18, + R.id.widget_graph_bar_container_19, + R.id.widget_graph_bar_container_20, + R.id.widget_graph_bar_container_21, + R.id.widget_graph_bar_container_22, + R.id.widget_graph_bar_container_23, +) + +private val blockedBarIds = listOf( + R.id.widget_graph_bar_0, + R.id.widget_graph_bar_1, + R.id.widget_graph_bar_2, + R.id.widget_graph_bar_3, + R.id.widget_graph_bar_4, + R.id.widget_graph_bar_5, + R.id.widget_graph_bar_6, + R.id.widget_graph_bar_7, + R.id.widget_graph_bar_8, + R.id.widget_graph_bar_9, + R.id.widget_graph_bar_10, + R.id.widget_graph_bar_11, + R.id.widget_graph_bar_12, + R.id.widget_graph_bar_13, + R.id.widget_graph_bar_14, + R.id.widget_graph_bar_15, + R.id.widget_graph_bar_16, + R.id.widget_graph_bar_17, + R.id.widget_graph_bar_18, + R.id.widget_graph_bar_19, + R.id.widget_graph_bar_20, + R.id.widget_graph_bar_21, + R.id.widget_graph_bar_22, + R.id.widget_graph_bar_23 +) + +private val leakedBarIds = listOf( + R.id.widget_leaked_graph_bar_0, + R.id.widget_leaked_graph_bar_1, + R.id.widget_leaked_graph_bar_2, + R.id.widget_leaked_graph_bar_3, + R.id.widget_leaked_graph_bar_4, + R.id.widget_leaked_graph_bar_5, + R.id.widget_leaked_graph_bar_6, + R.id.widget_leaked_graph_bar_7, + R.id.widget_leaked_graph_bar_8, + R.id.widget_leaked_graph_bar_9, + R.id.widget_leaked_graph_bar_10, + R.id.widget_leaked_graph_bar_11, + R.id.widget_leaked_graph_bar_12, + R.id.widget_leaked_graph_bar_13, + R.id.widget_leaked_graph_bar_14, + R.id.widget_leaked_graph_bar_15, + R.id.widget_leaked_graph_bar_16, + R.id.widget_leaked_graph_bar_17, + R.id.widget_leaked_graph_bar_18, + R.id.widget_leaked_graph_bar_19, + R.id.widget_leaked_graph_bar_20, + R.id.widget_leaked_graph_bar_21, + R.id.widget_leaked_graph_bar_22, + R.id.widget_leaked_graph_bar_23 +) + +private const val REQUEST_CODE_DASHBOARD = 1 +private const val REQUEST_CODE_TRACKERS = 3 +private const val REQUEST_CODE_TOGGLE_TRACKERS = 4 +private const val REQUEST_CODE_TOGGLE_LOCATION = 5 +private const val REQUEST_CODE_TOGGLE_IPSCRAMBLING = 6 +private const val REQUEST_CODE_HIGHLIGHT = 100 + +fun applyDarkText(context: Context, state: State, views: RemoteViews) { + views.apply { + listOf( + R.id.state_label, + R.id.graph_legend_blocked, + R.id.graph_legend_allowed, + + ) + .forEach { + setTextColor( + it, + context.getColor(if (isDarkText) R.color.on_surface_disabled_light else R.color.on_primary_medium_emphasis) + ) + } + setTextColor( + R.id.widget_title, + context.getColor(if (isDarkText) R.color.on_surface_medium_emphasis_light else R.color.on_surface_high_emphasis) + ) + listOf( + R.id.state_trackers, + R.id.state_geolocation, + R.id.state_ip_address, + R.id.graph_legend, + R.id.graph_view_trackers_btn + ) + .forEach { + setTextColor( + it, + context.getColor(if (isDarkText) R.color.on_surface_medium_emphasis_light else R.color.on_primary_high_emphasis) + ) + } + + listOf( + R.id.trackers_label, + R.id.geolocation_label, + R.id.ip_address_label, + R.id.graph_empty + + ) + .forEach { + setTextColor( + it, + context.getColor(if (isDarkText) R.color.on_surface_disabled_light else R.color.on_primary_disabled) + ) + } + setTextViewCompoundDrawables( + R.id.graph_view_trackers_btn, + 0, + 0, + if (isDarkText) R.drawable.ic_chevron_right_24dp_light else R.drawable.ic_chevron_right_24dp, + 0 + ) + setImageViewResource( + R.id.settings_btn, + if (isDarkText) R.drawable.ic_settings_light else R.drawable.ic_settings + ) + setImageViewResource( + R.id.state_icon, + if (isDarkText) { + if (state.quickPrivacyState.isEnabled()) R.drawable.ic_shield_on_light + else R.drawable.ic_shield_off_light + } else { + if (state.quickPrivacyState.isEnabled()) R.drawable.ic_shield_on_white + else R.drawable.ic_shield_off_white + } + ) + } +} |