Allow the user to exclude apps from backup

Closes #70
This commit is contained in:
Torsten Grote 2020-01-20 11:37:51 -03:00
parent c92b9a3606
commit 324da2a9e9
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
14 changed files with 185 additions and 24 deletions

View file

@ -68,7 +68,7 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
}
internal enum class AppRestoreStatus {
enum class AppRestoreStatus {
IN_PROGRESS,
SUCCEEDED,
FAILED,

View file

@ -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<AppStatusViewHolder>() {
internal class AppStatusAdapter(private val toggleListener: AppStatusToggleListener) : Adapter<AppStatusViewHolder>() {
private val items = ArrayList<AppStatus>()
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<AppStatusViewHolder>() {
holder.bind(items[position])
}
fun setEditMode(enabled: Boolean) {
editMode = enabled
notifyDataSetChanged()
}
fun update(newItems: List<AppStatus>, 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,

View file

@ -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)
}
}

View file

@ -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<String> 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(

View file

@ -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<AppStatusResult> = mAppStatusList
private val mAppEditMode = MutableLiveData<Boolean>()
internal val appEditMode: LiveData<Boolean> = 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)
}
}

View file

@ -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.")

View file

@ -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

View file

@ -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) {

View file

@ -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"

View file

@ -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"

View file

@ -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">
<ImageView
android:id="@+id/appIcon"
@ -26,7 +29,7 @@
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintBottom_toTopOf="@+id/appInfo"
app:layout_constraintEnd_toStartOf="@+id/appStatus"
app:layout_constraintEnd_toStartOf="@+id/switchView"
app:layout_constraintStart_toEndOf="@+id/appIcon"
app:layout_constraintTop_toTopOf="parent"
tools:text="Seedvault Backup" />
@ -57,10 +60,20 @@
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_width="24dp"
android:layout_height="24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Switch
android:id="@+id/switchView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/edit_app_blacklist"
android:checkable="true"
android:title="@string/settings_backup_exclude_apps"
app:showAsAction="never" />
</menu>

View file

@ -27,6 +27,7 @@
<string name="settings_backup_apk_dialog_disable">Disable app backup</string>
<string name="settings_backup_status_title">App backup status</string>
<string name="settings_backup_status_summary">Last Backup: %1$s</string>
<string name="settings_backup_exclude_apps">Exclude apps</string>
<string name="settings_backup_now">Backup now</string>
<!-- Storage -->

View file

@ -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()