From 324da2a9e91b9a96323ca4de1a7debd2131e434a Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 20 Jan 2020 11:37:51 -0300 Subject: [PATCH] Allow the user to exclude apps from backup Closes #70 --- .../restore/RestoreProgressAdapter.kt | 2 +- .../seedvault/settings/AppStatusAdapter.kt | 55 +++++++++++++++++-- .../seedvault/settings/AppStatusFragment.kt | 38 ++++++++++++- .../seedvault/settings/SettingsManager.kt | 16 ++++++ .../seedvault/settings/SettingsViewModel.kt | 16 ++++++ .../transport/backup/BackupCoordinator.kt | 13 +++-- .../stevesoltys/seedvault/ui/AppViewHolder.kt | 11 +++- .../ui/RequireProvisioningViewModel.kt | 2 +- .../main/res/layout/fragment_app_status.xml | 3 - .../res/layout/fragment_restore_progress.xml | 3 +- .../main/res/layout/list_item_app_status.xml | 23 ++++++-- app/src/main/res/menu/app_status_menu.xml | 10 ++++ app/src/main/res/values/strings.xml | 1 + .../transport/backup/BackupCoordinatorTest.kt | 16 ++++++ 14 files changed, 185 insertions(+), 24 deletions(-) create mode 100644 app/src/main/res/menu/app_status_menu.xml diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt index 310e4a6b..b22dd354 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt @@ -68,7 +68,7 @@ internal class RestoreProgressAdapter : Adapter() { } -internal enum class AppRestoreStatus { +enum class AppRestoreStatus { IN_PROGRESS, SUCCEEDED, FAILED, diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusAdapter.kt index 76c7ca0a..291f88c6 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusAdapter.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusAdapter.kt @@ -3,11 +3,14 @@ package com.stevesoltys.seedvault.settings import android.graphics.drawable.Drawable import android.view.LayoutInflater import android.view.View +import android.view.View.GONE +import android.view.View.INVISIBLE import android.view.View.VISIBLE import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil.DiffResult import androidx.recyclerview.widget.RecyclerView.Adapter +import androidx.recyclerview.widget.RecyclerView.NO_POSITION import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.restore.AppRestoreStatus import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED @@ -15,9 +18,10 @@ import com.stevesoltys.seedvault.settings.AppStatusAdapter.AppStatusViewHolder import com.stevesoltys.seedvault.ui.AppViewHolder import com.stevesoltys.seedvault.ui.toRelativeTime -internal class AppStatusAdapter : Adapter() { +internal class AppStatusAdapter(private val toggleListener: AppStatusToggleListener) : Adapter() { private val items = ArrayList() + private var editMode = false override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppStatusViewHolder { val v = LayoutInflater.from(parent.context).inflate(R.layout.list_item_app_status, parent, false) @@ -30,28 +34,67 @@ internal class AppStatusAdapter : Adapter() { holder.bind(items[position]) } + fun setEditMode(enabled: Boolean) { + editMode = enabled + notifyDataSetChanged() + } + fun update(newItems: List, diff: DiffResult) { items.clear() items.addAll(newItems) diff.dispatchUpdatesTo(this) } + fun onItemChanged(item: AppStatus) { + val pos = items.indexOfFirst { it.packageName == item.packageName } + if (pos != NO_POSITION) notifyItemChanged(pos, item) + } + inner class AppStatusViewHolder(v: View) : AppViewHolder(v) { fun bind(item: AppStatus) { appName.text = item.name appIcon.setImageDrawable(item.icon) - setStatus(item.status) - if (item.status == SUCCEEDED) { - appInfo.text = item.time.toRelativeTime(context) - appInfo.visibility = VISIBLE + if (editMode) { + v.background = clickableBackground + v.setOnClickListener { + switchView.toggle() + item.enabled = switchView.isChecked + toggleListener.onAppStatusToggled(item) + } + appInfo.visibility = GONE + appStatus.visibility = INVISIBLE + progressBar.visibility = INVISIBLE + switchView.visibility = VISIBLE + switchView.isChecked = item.enabled + } else { + v.background = null + v.setOnClickListener(null) + setStatus(item.status) + if (item.status == SUCCEEDED) { + appInfo.text = item.time.toRelativeTime(context) + appInfo.visibility = VISIBLE + } + switchView.visibility = INVISIBLE } + // show disabled items differently + showEnabled(item.enabled) + } + + private fun showEnabled(enabled: Boolean) { + val alpha = if (enabled) 1.0f else 0.5f + // setting the alpha on root view v only doesn't work as the ItemAnimator messes with it + appIcon.alpha = alpha + appName.alpha = alpha + appInfo.alpha = alpha + appStatus.alpha = alpha } } } -internal data class AppStatus( +data class AppStatus( val packageName: String, + var enabled: Boolean, val icon: Drawable, val name: String, val time: Long, diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt index 69fc1a60..bd442158 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt @@ -2,6 +2,9 @@ package com.stevesoltys.seedvault.settings import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.View.INVISIBLE import android.view.View.VISIBLE @@ -13,15 +16,21 @@ import com.stevesoltys.seedvault.R import kotlinx.android.synthetic.main.fragment_app_status.* import org.koin.androidx.viewmodel.ext.android.sharedViewModel -class AppStatusFragment : Fragment() { +internal interface AppStatusToggleListener { + fun onAppStatusToggled(status: AppStatus) +} + +class AppStatusFragment : Fragment(), AppStatusToggleListener { private val viewModel: SettingsViewModel by sharedViewModel() private val layoutManager = LinearLayoutManager(context) - private val adapter = AppStatusAdapter() + private val adapter = AppStatusAdapter(this) + private lateinit var appEditMenuItem: MenuItem override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + setHasOptionsMenu(true) return inflater.inflate(R.layout.fragment_app_status, container, false) } @@ -42,4 +51,29 @@ class AppStatusFragment : Fragment() { }) } + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.app_status_menu, menu) + appEditMenuItem = menu.findItem(R.id.edit_app_blacklist) + + // observe edit mode changes here where we are sure to have the MenuItem + viewModel.appEditMode.observe(this, Observer { enabled -> + appEditMenuItem.isChecked = enabled + adapter.setEditMode(enabled) + }) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + R.id.edit_app_blacklist -> { + viewModel.setEditMode(!item.isChecked) + true + } + else -> super.onOptionsItemSelected(item) + } + + override fun onAppStatusToggled(status: AppStatus) { + adapter.onItemChanged(status) + viewModel.onAppStatusToggled(status) + } + } diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt index 52202e23..949bc2d9 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt @@ -3,6 +3,7 @@ package com.stevesoltys.seedvault.settings import android.content.Context import android.hardware.usb.UsbDevice import android.net.Uri +import androidx.annotation.UiThread import androidx.documentfile.provider.DocumentFile import androidx.preference.PreferenceManager import java.util.concurrent.atomic.AtomicBoolean @@ -18,12 +19,18 @@ private const val PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER = "flashSerialNumber" private const val PREF_KEY_FLASH_DRIVE_VENDOR_ID = "flashDriveVendorId" private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId" +private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist" + class SettingsManager(context: Context) { private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private var isStorageChanging: AtomicBoolean = AtomicBoolean(false) + private val blacklistedApps: HashSet by lazy { + prefs.getStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, emptySet()).toHashSet() + } + // FIXME Storage is currently plugin specific and not generic fun setStorage(storage: Storage) { prefs.edit() @@ -76,6 +83,15 @@ class SettingsManager(context: Context) { return prefs.getBoolean(PREF_KEY_BACKUP_APK, true) } + fun isBackupEnabled(packageName: String) = !blacklistedApps.contains(packageName) + + @UiThread + fun onAppBackupStatusChanged(status: AppStatus) { + if (status.enabled) blacklistedApps.remove(status.packageName) + else blacklistedApps.add(status.packageName) + prefs.edit().putStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, blacklistedApps).apply() + } + } data class Storage( diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index 209da942..a841d2f1 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -3,8 +3,10 @@ package com.stevesoltys.seedvault.settings import android.app.Application import android.content.pm.PackageManager.NameNotFoundException import android.util.Log +import androidx.annotation.UiThread import androidx.core.content.ContextCompat.getDrawable import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations.switchMap import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope @@ -47,6 +49,9 @@ class SettingsViewModel( private val mAppStatusList = switchMap(lastBackupTime) { getAppStatusResult() } internal val appStatusList: LiveData = mAppStatusList + private val mAppEditMode = MutableLiveData() + internal val appEditMode: LiveData = mAppEditMode + init { viewModelScope.launch(Dispatchers.IO) { // ensures the lastBackupTime LiveData gets set @@ -91,6 +96,7 @@ class SettingsViewModel( } AppStatus( packageName = it.packageName, + enabled = settingsManager.isBackupEnabled(it.packageName), icon = icon, name = getAppName(app, it.packageName).toString(), time = time, @@ -102,4 +108,14 @@ class SettingsViewModel( emit(AppStatusResult(list, diff)) } + @UiThread + fun setEditMode(enabled: Boolean) { + mAppEditMode.value = enabled + } + + @UiThread + fun onAppStatusToggled(status: AppStatus) { + settingsManager.onAppBackupStatusChanged(status) + } + } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt index 5b673ccd..e6df7cf1 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt @@ -90,9 +90,13 @@ internal class BackupCoordinator( } fun isAppEligibleForBackup(targetPackage: PackageInfo, @Suppress("UNUSED_PARAMETER") isFullBackup: Boolean): Boolean { + val packageName = targetPackage.packageName + // Check that the app is not blacklisted by the user + val enabled = settingsManager.isBackupEnabled(packageName) + if (!enabled) Log.w(TAG, "Backup of $packageName disabled by user.") // We need to exclude the DocumentsProvider used to store backup data. // Otherwise, it gets killed when we back it up, terminating our backup. - return targetPackage.packageName != plugin.providerPackageName + return enabled && targetPackage.packageName != plugin.providerPackageName } /** @@ -104,9 +108,10 @@ internal class BackupCoordinator( * @return Current limit on backup size in bytes. */ fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { - // try to back up APK here as later methods are sometimes not called called - val pm = context.packageManager - backUpApk(pm.getPackageInfo(packageName, GET_SIGNING_CERTIFICATES)) + if (packageName != MAGIC_PACKAGE_MANAGER) { + // try to back up APK here as later methods are sometimes not called called + backUpApk(context.packageManager.getPackageInfo(packageName, GET_SIGNING_CERTIFICATES)) + } // report back quota Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.") diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt index 32ba6cf4..5c633c85 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt @@ -8,6 +8,7 @@ import android.view.View.INVISIBLE import android.view.View.VISIBLE import android.widget.ImageView import android.widget.ProgressBar +import android.widget.Switch import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.stevesoltys.seedvault.R @@ -21,18 +22,26 @@ import com.stevesoltys.seedvault.restore.AppRestoreStatus.IN_PROGRESS import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED -internal open class AppViewHolder(v: View) : RecyclerView.ViewHolder(v) { +internal open class AppViewHolder(protected val v: View) : RecyclerView.ViewHolder(v) { protected val context: Context = v.context protected val pm: PackageManager = context.packageManager + protected val clickableBackground = v.background!! protected val appIcon: ImageView = v.findViewById(R.id.appIcon) protected val appName: TextView = v.findViewById(R.id.appName) protected val appInfo: TextView = v.findViewById(R.id.appInfo) protected val appStatus: ImageView = v.findViewById(R.id.appStatus) protected val progressBar: ProgressBar = v.findViewById(R.id.progressBar) + protected val switchView: Switch = v.findViewById(R.id.switchView) + + init { + // don't use clickable background by default + v.background = null + } protected fun setStatus(status: AppRestoreStatus) { + v.background = null if (status == IN_PROGRESS) { appInfo.visibility = GONE appStatus.visibility = INVISIBLE diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt index 3f2559e4..6527fd2f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt @@ -8,7 +8,7 @@ import com.stevesoltys.seedvault.ui.storage.StorageViewModel abstract class RequireProvisioningViewModel( protected val app: Application, - private val settingsManager: SettingsManager, + protected val settingsManager: SettingsManager, private val keyManager: KeyManager ) : AndroidViewModel(app) { diff --git a/app/src/main/res/layout/fragment_app_status.xml b/app/src/main/res/layout/fragment_app_status.xml index 4dccb26c..904afb0b 100644 --- a/app/src/main/res/layout/fragment_app_status.xml +++ b/app/src/main/res/layout/fragment_app_status.xml @@ -8,9 +8,6 @@ android:id="@+id/list" android:layout_width="0dp" android:layout_height="0dp" - android:paddingStart="16dp" - android:paddingEnd="16dp" - android:scrollbarStyle="outsideOverlay" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout/fragment_restore_progress.xml b/app/src/main/res/layout/fragment_restore_progress.xml index 68f9eeb9..3254843d 100644 --- a/app/src/main/res/layout/fragment_restore_progress.xml +++ b/app/src/main/res/layout/fragment_restore_progress.xml @@ -58,7 +58,8 @@ android:id="@+id/appList" android:layout_width="0dp" android:layout_height="0dp" - android:layout_margin="16dp" + android:layout_marginTop="16dp" + android:layout_marginBottom="16dp" app:layout_constraintBottom_toTopOf="@+id/button" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@+id/backupNameView" diff --git a/app/src/main/res/layout/list_item_app_status.xml b/app/src/main/res/layout/list_item_app_status.xml index e7a0236d..25801a7c 100644 --- a/app/src/main/res/layout/list_item_app_status.xml +++ b/app/src/main/res/layout/list_item_app_status.xml @@ -4,8 +4,11 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:layout_marginBottom="8dp"> + android:background="?android:selectableItemBackground" + android:paddingStart="16dp" + android:paddingTop="8dp" + android:paddingEnd="16dp" + android:paddingBottom="8dp"> @@ -57,10 +60,20 @@ + + diff --git a/app/src/main/res/menu/app_status_menu.xml b/app/src/main/res/menu/app_status_menu.xml new file mode 100644 index 00000000..ed96c800 --- /dev/null +++ b/app/src/main/res/menu/app_status_menu.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d5e62f47..701963ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,6 +27,7 @@ Disable app backup App backup status Last Backup: %1$s + Exclude apps Backup now diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt index 63c0e741..4136e361 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt @@ -23,7 +23,9 @@ import io.mockk.just import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.io.IOException import java.io.OutputStream @@ -122,6 +124,20 @@ internal class BackupCoordinatorTest : BackupTest() { assertEquals(quota, backup.getBackupQuota(packageInfo.packageName, isFullBackup)) } + @Test + fun `isAppEligibleForBackup() exempts plugin provider and blacklisted apps`() { + every { + settingsManager.isBackupEnabled(packageInfo.packageName) + } returns true andThen false andThen true + every { + plugin.providerPackageName + } returns packageInfo.packageName andThen "new.package" andThen "new.package" + + assertFalse(backup.isAppEligibleForBackup(packageInfo, true)) + assertFalse(backup.isAppEligibleForBackup(packageInfo, true)) + assertTrue(backup.isAppEligibleForBackup(packageInfo, true)) + } + @Test fun `clearing KV backup data throws`() { every { kv.clearBackupData(packageInfo) } throws IOException()