Merge pull request #71 from grote/69-app-backup-status

Show list of apps and their backup status
This commit is contained in:
Steve Soltys 2020-01-19 12:50:46 -05:00 committed by GitHub
commit 3ae600ea8e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 474 additions and 140 deletions

View file

@ -29,6 +29,9 @@ android {
targetCompatibility 1.8 targetCompatibility 1.8
sourceCompatibility 1.8 sourceCompatibility 1.8
} }
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
testOptions { testOptions {
unitTests.all { unitTests.all {
useJUnitPlatform() useJUnitPlatform()

View file

@ -7,7 +7,6 @@ import android.app.NotificationManager.IMPORTANCE_HIGH
import android.app.NotificationManager.IMPORTANCE_LOW import android.app.NotificationManager.IMPORTANCE_LOW
import android.app.PendingIntent import android.app.PendingIntent
import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.PackageManager.NameNotFoundException
@ -19,6 +18,7 @@ import androidx.core.app.NotificationCompat.PRIORITY_LOW
import com.stevesoltys.seedvault.restore.ACTION_RESTORE_ERROR_UNINSTALL import com.stevesoltys.seedvault.restore.ACTION_RESTORE_ERROR_UNINSTALL
import com.stevesoltys.seedvault.restore.EXTRA_PACKAGE_NAME import com.stevesoltys.seedvault.restore.EXTRA_PACKAGE_NAME
import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL
import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST
import com.stevesoltys.seedvault.settings.SettingsActivity import com.stevesoltys.seedvault.settings.SettingsActivity
private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver" private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver"
@ -78,23 +78,6 @@ class BackupNotificationManager(private val context: Context) {
nm.notify(NOTIFICATION_ID_OBSERVER, notification) nm.notify(NOTIFICATION_ID_OBSERVER, notification)
} }
fun onBackupResult(app: CharSequence, status: Int, userInitiated: Boolean) {
val title = context.getString(when (status) {
0 -> R.string.notification_backup_result_complete
TRANSPORT_PACKAGE_REJECTED -> R.string.notification_backup_result_rejected
else -> R.string.notification_backup_result_error
})
val notification = observerBuilder.apply {
setContentTitle(title)
setContentText(app)
setOngoing(true)
setShowWhen(false)
setWhen(System.currentTimeMillis())
priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW
}.build()
nm.notify(NOTIFICATION_ID_OBSERVER, notification)
}
fun onBackupFinished(success: Boolean, notBackedUp: Int?, userInitiated: Boolean) { fun onBackupFinished(success: Boolean, notBackedUp: Int?, userInitiated: Boolean) {
if (!userInitiated) { if (!userInitiated) {
nm.cancel(NOTIFICATION_ID_OBSERVER) nm.cancel(NOTIFICATION_ID_OBSERVER)
@ -104,11 +87,19 @@ class BackupNotificationManager(private val context: Context) {
val contentText = if (notBackedUp == null) null else { val contentText = if (notBackedUp == null) null else {
context.getString(R.string.notification_success_num_not_backed_up, notBackedUp) context.getString(R.string.notification_success_num_not_backed_up, notBackedUp)
} }
val iconRes = if (success) R.drawable.ic_cloud_done else R.drawable.ic_cloud_error
val intent = Intent(context, SettingsActivity::class.java).apply {
action = ACTION_APP_STATUS_LIST
}
val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
val notification = observerBuilder.apply { val notification = observerBuilder.apply {
setContentTitle(context.getString(titleRes)) setContentTitle(context.getString(titleRes))
setContentText(contentText) setContentText(contentText)
setOngoing(false) setOngoing(false)
setShowWhen(true) setShowWhen(true)
setAutoCancel(true)
setSmallIcon(iconRes)
setContentIntent(pendingIntent)
setWhen(System.currentTimeMillis()) setWhen(System.currentTimeMillis())
setProgress(0, 0, false) setProgress(0, 0, false)
priority = PRIORITY_LOW priority = PRIORITY_LOW

View file

@ -1,6 +1,5 @@
package com.stevesoltys.seedvault.crypto package com.stevesoltys.seedvault.crypto
import android.os.Build.VERSION.SDK_INT
import android.security.keystore.KeyProperties.BLOCK_MODE_GCM import android.security.keystore.KeyProperties.BLOCK_MODE_GCM
import android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE import android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE
import android.security.keystore.KeyProperties.PURPOSE_DECRYPT import android.security.keystore.KeyProperties.PURPOSE_DECRYPT
@ -48,7 +47,7 @@ internal class KeyManagerImpl : KeyManager {
override fun storeBackupKey(seed: ByteArray) { override fun storeBackupKey(seed: ByteArray) {
if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException() if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException()
// TODO check if using first 256 of 512 bytes produced by PBKDF2WithHmacSHA512 is safe! // TODO check if using first 256 of 512 bits produced by PBKDF2WithHmacSHA512 is safe!
val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES") val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES")
val ksEntry = SecretKeyEntry(secretKeySpec) val ksEntry = SecretKeyEntry(secretKeySpec)
keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection()) keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection())
@ -68,7 +67,7 @@ internal class KeyManagerImpl : KeyManager {
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE) .setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true) .setRandomizedEncryptionRequired(true)
// unlocking is required only for decryption, so when restoring from backup // unlocking is required only for decryption, so when restoring from backup
if (SDK_INT >= 28) builder.setUnlockedDeviceRequired(true) builder.setUnlockedDeviceRequired(true)
return builder.build() return builder.build()
} }

View file

@ -8,6 +8,9 @@ import android.content.pm.PackageInfo
import android.util.Log import android.util.Log
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.distinctUntilChanged
import com.stevesoltys.seedvault.Clock import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
@ -37,6 +40,7 @@ class MetadataManager(
// If this happens, it is hard to recover from this. Let's hope it never does. // If this happens, it is hard to recover from this. Let's hope it never does.
throw AssertionError("Error reading metadata from cache", e) throw AssertionError("Error reading metadata from cache", e)
} }
mLastBackupTime.postValue(field.time)
} }
return field return field
} }
@ -155,6 +159,7 @@ class MetadataManager(
metadata = oldMetadata metadata = oldMetadata
throw IOException(e) throw IOException(e)
} }
mLastBackupTime.postValue(metadata.time)
} }
/** /**
@ -171,7 +176,10 @@ class MetadataManager(
* Note that this might be a blocking I/O call. * Note that this might be a blocking I/O call.
*/ */
@Synchronized @Synchronized
fun getLastBackupTime(): Long = metadata.time fun getLastBackupTime(): Long = mLastBackupTime.value ?: metadata.time
private val mLastBackupTime = MutableLiveData<Long>()
internal val lastBackupTime: LiveData<Long> = mLastBackupTime.distinctUntilChanged()
@Synchronized @Synchronized
fun getPackageMetadata(packageName: String): PackageMetadata? { fun getPackageMetadata(packageName: String): PackageMetadata? {

View file

@ -5,11 +5,7 @@ import android.view.View
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.Adapter import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.recyclerview.widget.SortedList import androidx.recyclerview.widget.SortedList
import androidx.recyclerview.widget.SortedListAdapterCallback import androidx.recyclerview.widget.SortedListAdapterCallback
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
@ -18,8 +14,9 @@ import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.FAILED
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.IN_PROGRESS import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.IN_PROGRESS
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.SUCCEEDED import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.SUCCEEDED
import com.stevesoltys.seedvault.ui.AppViewHolder
internal class InstallProgressAdapter : Adapter<AppViewHolder>() { internal class InstallProgressAdapter : Adapter<AppInstallViewHolder>() {
private val items = SortedList<ApkRestoreResult>(ApkRestoreResult::class.java, object : SortedListAdapterCallback<ApkRestoreResult>(this) { private val items = SortedList<ApkRestoreResult>(ApkRestoreResult::class.java, object : SortedListAdapterCallback<ApkRestoreResult>(this) {
override fun areItemsTheSame(item1: ApkRestoreResult, item2: ApkRestoreResult) = item1.packageName == item2.packageName override fun areItemsTheSame(item1: ApkRestoreResult, item2: ApkRestoreResult) = item1.packageName == item2.packageName
@ -27,14 +24,14 @@ internal class InstallProgressAdapter : Adapter<AppViewHolder>() {
override fun compare(item1: ApkRestoreResult, item2: ApkRestoreResult) = item1.compareTo(item2) override fun compare(item1: ApkRestoreResult, item2: ApkRestoreResult) = item1.compareTo(item2)
}) })
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppInstallViewHolder {
val v = LayoutInflater.from(parent.context).inflate(R.layout.list_item_app_status, parent, false) val v = LayoutInflater.from(parent.context).inflate(R.layout.list_item_app_status, parent, false)
return AppViewHolder(v) return AppInstallViewHolder(v)
} }
override fun getItemCount() = items.size() override fun getItemCount() = items.size()
override fun onBindViewHolder(holder: AppViewHolder, position: Int) { override fun onBindViewHolder(holder: AppInstallViewHolder, position: Int) {
holder.bind(items[position]) holder.bind(items[position])
} }
@ -43,12 +40,7 @@ internal class InstallProgressAdapter : Adapter<AppViewHolder>() {
} }
} }
internal class AppViewHolder(v: View) : ViewHolder(v) { internal class AppInstallViewHolder(v: View) : AppViewHolder(v) {
private val appIcon: ImageView = v.findViewById(R.id.appIcon)
private val appName: TextView = v.findViewById(R.id.appName)
private val appStatus: ImageView = v.findViewById(R.id.appStatus)
private val progressBar: ProgressBar = v.findViewById(R.id.progressBar)
fun bind(item: ApkRestoreResult) { fun bind(item: ApkRestoreResult) {
appIcon.setImageDrawable(item.icon) appIcon.setImageDrawable(item.icon)

View file

@ -3,26 +3,13 @@ package com.stevesoltys.seedvault.restore
import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.PackageManager.NameNotFoundException
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.View.GONE
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView.Adapter import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_INSTALLED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.IN_PROGRESS
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
import com.stevesoltys.seedvault.restore.RestoreProgressAdapter.PackageViewHolder import com.stevesoltys.seedvault.restore.RestoreProgressAdapter.PackageViewHolder
import com.stevesoltys.seedvault.ui.AppViewHolder
import java.util.* import java.util.*
internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() { internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
@ -63,16 +50,7 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
} }
} }
inner class PackageViewHolder(v: View) : ViewHolder(v) { inner class PackageViewHolder(v: View) : AppViewHolder(v) {
private val context = v.context
private val pm = context.packageManager
private val appIcon: ImageView = v.findViewById(R.id.appIcon)
private val appName: TextView = v.findViewById(R.id.appName)
private val appInfo: TextView = v.findViewById(R.id.appInfo)
private val appStatus: ImageView = v.findViewById(R.id.appStatus)
private val progressBar: ProgressBar = v.findViewById(R.id.progressBar)
fun bind(item: AppRestoreResult) { fun bind(item: AppRestoreResult) {
appName.text = item.name appName.text = item.name
if (item.packageName == MAGIC_PACKAGE_MANAGER) { if (item.packageName == MAGIC_PACKAGE_MANAGER) {
@ -84,38 +62,8 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
appIcon.setImageResource(R.drawable.ic_launcher_default) appIcon.setImageResource(R.drawable.ic_launcher_default)
} }
} }
if (item.status == IN_PROGRESS) { setStatus(item.status)
appInfo.visibility = GONE
appStatus.visibility = INVISIBLE
progressBar.visibility = VISIBLE
} else {
appStatus.visibility = VISIBLE
progressBar.visibility = INVISIBLE
appInfo.visibility = GONE
when (item.status) {
SUCCEEDED -> {
appStatus.setImageResource(R.drawable.ic_check_green)
} }
FAILED -> {
appStatus.setImageResource(R.drawable.ic_cancel_red)
}
else -> {
appStatus.setImageResource(R.drawable.ic_error_yellow)
appInfo.text = getInfo(item.status)
appInfo.visibility = VISIBLE
}
}
}
}
private fun getInfo(status: AppRestoreStatus): String = when(status) {
FAILED_NO_DATA -> context.getString(R.string.restore_app_no_data)
FAILED_NOT_ALLOWED -> context.getString(R.string.restore_app_not_allowed)
FAILED_NOT_INSTALLED -> context.getString(R.string.restore_app_not_installed)
FAILED_QUOTA_EXCEEDED -> context.getString(R.string.restore_app_quota_exceeded)
else -> "Please report a bug after you read this."
}
} }
} }

View file

@ -0,0 +1,79 @@
package com.stevesoltys.seedvault.settings
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
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 com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.AppRestoreStatus
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
import com.stevesoltys.seedvault.settings.AppStatusAdapter.AppStatusViewHolder
import com.stevesoltys.seedvault.ui.AppViewHolder
import com.stevesoltys.seedvault.ui.toRelativeTime
internal class AppStatusAdapter : Adapter<AppStatusViewHolder>() {
private val items = ArrayList<AppStatus>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppStatusViewHolder {
val v = LayoutInflater.from(parent.context).inflate(R.layout.list_item_app_status, parent, false)
return AppStatusViewHolder(v)
}
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: AppStatusViewHolder, position: Int) {
holder.bind(items[position])
}
fun update(newItems: List<AppStatus>, diff: DiffResult) {
items.clear()
items.addAll(newItems)
diff.dispatchUpdatesTo(this)
}
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
}
}
}
}
internal data class AppStatus(
val packageName: String,
val icon: Drawable,
val name: String,
val time: Long,
val status: AppRestoreStatus)
internal class AppStatusDiff(
private val oldItems: List<AppStatus>,
private val newItems: List<AppStatus>) : DiffUtil.Callback() {
override fun getOldListSize() = oldItems.size
override fun getNewListSize() = newItems.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldItems[oldItemPosition].packageName == newItems[newItemPosition].packageName
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldItems[oldItemPosition] == newItems[newItemPosition]
}
}
internal class AppStatusResult(
val appStatusList: List<AppStatus>,
val diff: DiffResult
)

View file

@ -0,0 +1,45 @@
package com.stevesoltys.seedvault.settings
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_app_status.*
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class AppStatusFragment : Fragment() {
private val viewModel: SettingsViewModel by sharedViewModel()
private val layoutManager = LinearLayoutManager(context)
private val adapter = AppStatusAdapter()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_app_status, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
activity?.setTitle(R.string.settings_backup_status_title)
list.apply {
layoutManager = this@AppStatusFragment.layoutManager
adapter = this@AppStatusFragment.adapter
}
progressBar.visibility = VISIBLE
viewModel.appStatusList.observe(this, Observer { result ->
adapter.update(result.appStatusList, result.diff)
progressBar.visibility = INVISIBLE
})
}
}

View file

@ -2,6 +2,9 @@ package com.stevesoltys.seedvault.settings
import android.os.Bundle import android.os.Bundle
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceFragmentCompat.OnPreferenceStartFragmentCallback
import com.stevesoltys.seedvault.BackupNotificationManager import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
@ -9,7 +12,9 @@ import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
class SettingsActivity : RequireProvisioningActivity() { internal const val ACTION_APP_STATUS_LIST = "com.stevesoltys.seedvault.APP_STATUS_LIST"
class SettingsActivity : RequireProvisioningActivity(), OnPreferenceStartFragmentCallback {
private val viewModel: SettingsViewModel by viewModel() private val viewModel: SettingsViewModel by viewModel()
private val notificationManager: BackupNotificationManager by inject() private val notificationManager: BackupNotificationManager by inject()
@ -23,7 +28,11 @@ class SettingsActivity : RequireProvisioningActivity() {
supportActionBar!!.setDisplayHomeAsUpEnabled(true) supportActionBar!!.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) showFragment(SettingsFragment()) if (intent?.action == ACTION_APP_STATUS_LIST) {
showFragment(AppStatusFragment())
} else if (savedInstanceState == null) {
showFragment(SettingsFragment())
}
} }
@CallSuper @CallSuper
@ -41,4 +50,14 @@ class SettingsActivity : RequireProvisioningActivity() {
} }
} }
override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat, pref: Preference): Boolean {
val fragment = supportFragmentManager.fragmentFactory.instantiate(classLoader, pref.fragment)
fragment.setTargetFragment(caller, 0)
supportFragmentManager.beginTransaction()
.replace(R.id.fragment, fragment)
.addToBackStack(null)
.commit()
return true
}
} }

View file

@ -12,8 +12,6 @@ import android.os.Bundle
import android.os.RemoteException import android.os.RemoteException
import android.provider.Settings import android.provider.Settings
import android.provider.Settings.Secure.BACKUP_AUTO_RESTORE import android.provider.Settings.Secure.BACKUP_AUTO_RESTORE
import android.text.format.DateUtils.MINUTE_IN_MILLIS
import android.text.format.DateUtils.getRelativeTimeSpanString
import android.util.Log import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
@ -28,6 +26,7 @@ import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.UsbMonitor import com.stevesoltys.seedvault.UsbMonitor
import com.stevesoltys.seedvault.isMassStorage import com.stevesoltys.seedvault.isMassStorage
import com.stevesoltys.seedvault.restore.RestoreActivity import com.stevesoltys.seedvault.restore.RestoreActivity
import com.stevesoltys.seedvault.ui.toRelativeTime
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
@ -43,6 +42,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var autoRestore: TwoStatePreference private lateinit var autoRestore: TwoStatePreference
private lateinit var apkBackup: TwoStatePreference private lateinit var apkBackup: TwoStatePreference
private lateinit var backupLocation: Preference private lateinit var backupLocation: Preference
private lateinit var backupStatus: Preference
private var menuBackupNow: MenuItem? = null private var menuBackupNow: MenuItem? = null
private var menuRestore: MenuItem? = null private var menuRestore: MenuItem? = null
@ -65,7 +65,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
setPreferencesFromResource(R.xml.settings, rootKey) setPreferencesFromResource(R.xml.settings, rootKey)
setHasOptionsMenu(true) setHasOptionsMenu(true)
backup = findPreference<TwoStatePreference>("backup")!! backup = findPreference("backup")!!
backup.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue -> backup.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean val enabled = newValue as Boolean
try { try {
@ -78,13 +78,13 @@ class SettingsFragment : PreferenceFragmentCompat() {
} }
} }
backupLocation = findPreference<Preference>("backup_location")!! backupLocation = findPreference("backup_location")!!
backupLocation.setOnPreferenceClickListener { backupLocation.setOnPreferenceClickListener {
viewModel.chooseBackupLocation() viewModel.chooseBackupLocation()
true true
} }
autoRestore = findPreference<TwoStatePreference>("auto_restore")!! autoRestore = findPreference("auto_restore")!!
autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue -> autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean val enabled = newValue as Boolean
try { try {
@ -115,8 +115,9 @@ class SettingsFragment : PreferenceFragmentCompat() {
.show() .show()
return@OnPreferenceChangeListener false return@OnPreferenceChangeListener false
} }
backupStatus = findPreference("backup_status")!!
viewModel.lastBackupTime.observe(this, Observer { time -> setBackupLocationSummary(time) }) viewModel.lastBackupTime.observe(this, Observer { time -> setAppBackupStatusSummary(time) })
} }
override fun onStart() { override fun onStart() {
@ -126,10 +127,10 @@ class SettingsFragment : PreferenceFragmentCompat() {
activity?.setTitle(R.string.backup) activity?.setTitle(R.string.backup)
storage = settingsManager.getStorage() storage = settingsManager.getStorage()
setBackupState() setBackupEnabledState()
setBackupLocationSummary()
setAutoRestoreState() setAutoRestoreState()
setMenuItemStates() setMenuItemStates()
viewModel.updateLastBackupTime()
if (storage?.isUsb == true) context?.registerReceiver(usbReceiver, usbFilter) if (storage?.isUsb == true) context?.registerReceiver(usbReceiver, usbFilter)
} }
@ -150,23 +151,23 @@ class SettingsFragment : PreferenceFragmentCompat() {
setMenuItemStates() setMenuItemStates()
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean = when { override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
item.itemId == R.id.action_backup -> { R.id.action_backup -> {
viewModel.backupNow() viewModel.backupNow()
true true
} }
item.itemId == R.id.action_restore -> { R.id.action_restore -> {
startActivity(Intent(requireContext(), RestoreActivity::class.java)) startActivity(Intent(requireContext(), RestoreActivity::class.java))
true true
} }
item.itemId == R.id.action_about -> { R.id.action_about -> {
AboutDialogFragment().show(fragmentManager!!, AboutDialogFragment.TAG) AboutDialogFragment().show(fragmentManager!!, AboutDialogFragment.TAG)
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
private fun setBackupState() { private fun setBackupEnabledState() {
try { try {
backup.isChecked = backupManager.isBackupEnabled backup.isChecked = backupManager.isBackupEnabled
backup.isEnabled = true backup.isEnabled = true
@ -189,17 +190,15 @@ class SettingsFragment : PreferenceFragmentCompat() {
} }
} }
private fun setBackupLocationSummary(lastBackupInMillis: Long) { private fun setBackupLocationSummary() {
// get name of storage location // get name of storage location
val storageName = storage?.name ?: getString(R.string.settings_backup_location_none) backupLocation.summary = storage?.name ?: getString(R.string.settings_backup_location_none)
// set time of last backup
val lastBackup = if (lastBackupInMillis == 0L) {
getString(R.string.settings_backup_last_backup_never)
} else {
getRelativeTimeSpanString(lastBackupInMillis, System.currentTimeMillis(), MINUTE_IN_MILLIS, 0)
} }
backupLocation.summary = getString(R.string.settings_backup_location_summary, storageName, lastBackup)
private fun setAppBackupStatusSummary(lastBackupInMillis: Long) {
// set time of last backup
val lastBackup = lastBackupInMillis.toRelativeTime(requireContext())
backupStatus.summary = getString(R.string.settings_backup_status_summary, lastBackup)
} }
private fun setMenuItemStates() { private fun setMenuItemStates() {

View file

@ -1,12 +1,37 @@
package com.stevesoltys.seedvault.settings package com.stevesoltys.seedvault.settings
import android.app.Application import android.app.Application
import android.content.pm.PackageManager.NameNotFoundException
import android.util.Log
import androidx.core.content.ContextCompat.getDrawable
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations.switchMap
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.DiffUtil.calculateDiff
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.getAppName
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.isSystemApp
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
import com.stevesoltys.seedvault.transport.requestBackup import com.stevesoltys.seedvault.transport.requestBackup
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
private val TAG = SettingsViewModel::class.java.simpleName
class SettingsViewModel( class SettingsViewModel(
app: Application, app: Application,
@ -17,15 +42,64 @@ class SettingsViewModel(
override val isRestoreOperation = false override val isRestoreOperation = false
private val _lastBackupTime = MutableLiveData<Long>() internal val lastBackupTime = metadataManager.lastBackupTime
internal val lastBackupTime: LiveData<Long> = _lastBackupTime
internal fun updateLastBackupTime() { private val mAppStatusList = switchMap(lastBackupTime) { getAppStatusResult() }
Thread { _lastBackupTime.postValue(metadataManager.getLastBackupTime()) }.start() internal val appStatusList: LiveData<AppStatusResult> = mAppStatusList
init {
viewModelScope.launch(Dispatchers.IO) {
// ensures the lastBackupTime LiveData gets set
metadataManager.getLastBackupTime()
}
} }
internal fun backupNow() { internal fun backupNow() {
Thread { requestBackup(app) }.start() Thread { requestBackup(app) }.start()
} }
private fun getAppStatusResult(): LiveData<AppStatusResult> = liveData(Dispatchers.Main) {
val pm = app.packageManager
val locale = Locale.getDefault()
val list = pm.getInstalledPackages(0)
.filter { !it.isSystemApp() }
.map {
val icon = if (it.packageName == MAGIC_PACKAGE_MANAGER) {
getDrawable(app, R.drawable.ic_launcher_default)!!
} else {
try {
pm.getApplicationIcon(it.packageName)
} catch (e: NameNotFoundException) {
getDrawable(app, R.drawable.ic_launcher_default)!!
}
}
val metadata = metadataManager.getPackageMetadata(it.packageName)
val time = metadata?.time ?: 0
val status = when (metadata?.state) {
null -> {
Log.w(TAG, "No metadata available for: ${it.packageName}")
FAILED
}
NO_DATA -> FAILED_NO_DATA
NOT_ALLOWED -> FAILED_NOT_ALLOWED
QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED
UNKNOWN_ERROR -> FAILED
APK_AND_DATA -> SUCCEEDED
}
if (metadata?.hasApk() == false) {
Log.w(TAG, "No APK stored for: ${it.packageName}")
}
AppStatus(
packageName = it.packageName,
icon = icon,
name = getAppName(app, it.packageName).toString(),
time = time,
status = status
)
}.sortedBy { it.name.toLowerCase(locale) }
val oldList = mAppStatusList.value?.appStatusList ?: emptyList()
val diff = calculateDiff(AppStatusDiff(oldList, list))
emit(AppStatusResult(list, diff))
}
} }

View file

@ -6,6 +6,7 @@ import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
import android.content.Context import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.BackupNotificationManager import com.stevesoltys.seedvault.BackupNotificationManager
@ -94,7 +95,20 @@ internal class BackupCoordinator(
return targetPackage.packageName != plugin.providerPackageName return targetPackage.packageName != plugin.providerPackageName
} }
/**
* Ask the transport about current quota for backup size of the package.
*
* @param packageName ID of package to provide the quota.
* @param isFullBackup If set, transport should return limit for full data backup,
* otherwise for key-value backup.
* @return Current limit on backup size in bytes.
*/
fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { 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))
// report back quota
Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.") Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.")
val quota = if (isFullBackup) full.getQuota() else kv.getQuota() val quota = if (isFullBackup) full.getQuota() else kv.getQuota()
Log.i(TAG, "Reported quota of $quota bytes.") Log.i(TAG, "Reported quota of $quota bytes.")
@ -132,8 +146,7 @@ internal class BackupCoordinator(
// hook in here to back up APKs of apps that are otherwise not allowed for backup // hook in here to back up APKs of apps that are otherwise not allowed for backup
backUpNotAllowedPackages() backUpNotAllowedPackages()
} }
val result = kv.performBackup(packageInfo, data, flags) return kv.performBackup(packageInfo, data, flags)
return backUpApk(result, packageInfo)
} }
// ------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------
@ -166,8 +179,7 @@ internal class BackupCoordinator(
fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int): Int { fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int): Int {
cancelReason = UNKNOWN_ERROR cancelReason = UNKNOWN_ERROR
val result = full.performFullBackup(targetPackage, fileDescriptor, flags) return full.performFullBackup(targetPackage, fileDescriptor, flags)
return backUpApk(result, targetPackage)
} }
fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes) fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes)
@ -254,27 +266,24 @@ internal class BackupCoordinator(
Log.d(TAG, "Checking if APKs of opt-out apps need backup...") Log.d(TAG, "Checking if APKs of opt-out apps need backup...")
packageService.notAllowedPackages.forEach { optOutPackageInfo -> packageService.notAllowedPackages.forEach { optOutPackageInfo ->
try { try {
backUpApk(0, optOutPackageInfo, NOT_ALLOWED) backUpApk(optOutPackageInfo, NOT_ALLOWED)
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error backing up opt-out APK of ${optOutPackageInfo.packageName}", e) Log.e(TAG, "Error backing up opt-out APK of ${optOutPackageInfo.packageName}", e)
} }
} }
} }
private fun backUpApk(result: Int, packageInfo: PackageInfo, packageState: PackageState = UNKNOWN_ERROR): Int { private fun backUpApk(packageInfo: PackageInfo, packageState: PackageState = UNKNOWN_ERROR) {
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
if (packageName == MAGIC_PACKAGE_MANAGER) return result try {
return try {
apkBackup.backupApkIfNecessary(packageInfo, packageState) { apkBackup.backupApkIfNecessary(packageInfo, packageState) {
plugin.getApkOutputStream(packageInfo) plugin.getApkOutputStream(packageInfo)
}?.let { packageMetadata -> }?.let { packageMetadata ->
val outputStream = plugin.getMetadataOutputStream() val outputStream = plugin.getMetadataOutputStream()
metadataManager.onApkBackedUp(packageInfo, packageMetadata, outputStream) metadataManager.onApkBackedUp(packageInfo, packageMetadata, outputStream)
} }
result
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error while writing APK or metadata for $packageName", e) Log.e(TAG, "Error while writing APK or metadata for $packageName", e)
TRANSPORT_PACKAGE_REJECTED
} }
} }

View file

@ -0,0 +1,68 @@
package com.stevesoltys.seedvault.ui
import android.content.Context
import android.content.pm.PackageManager
import android.view.View
import android.view.View.GONE
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.AppRestoreStatus
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_INSTALLED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.IN_PROGRESS
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
internal open class AppViewHolder(v: View) : RecyclerView.ViewHolder(v) {
protected val context: Context = v.context
protected val pm: PackageManager = context.packageManager
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 fun setStatus(status: AppRestoreStatus) {
if (status == IN_PROGRESS) {
appInfo.visibility = GONE
appStatus.visibility = INVISIBLE
progressBar.visibility = VISIBLE
} else {
appStatus.visibility = VISIBLE
progressBar.visibility = INVISIBLE
appInfo.visibility = GONE
when (status) {
SUCCEEDED -> appStatus.setImageResource(R.drawable.ic_check_green)
FAILED -> appStatus.setImageResource(R.drawable.ic_cancel_red)
else -> {
when (status) {
FAILED_NO_DATA -> appStatus.setImageResource(R.drawable.ic_radio_button_unchecked_yellow)
FAILED_NOT_ALLOWED -> appStatus.setImageResource(R.drawable.ic_block_yellow)
else -> appStatus.setImageResource(R.drawable.ic_error_yellow)
}
appInfo.text = status.getInfo()
appInfo.visibility = VISIBLE
}
}
}
}
private fun AppRestoreStatus.getInfo(): String = when (this) {
FAILED_NO_DATA -> context.getString(R.string.restore_app_no_data)
FAILED_NOT_ALLOWED -> context.getString(R.string.restore_app_not_allowed)
FAILED_NOT_INSTALLED -> context.getString(R.string.restore_app_not_installed)
FAILED_QUOTA_EXCEEDED -> context.getString(R.string.restore_app_quota_exceeded)
else -> "Please report a bug after you read this."
}
}

View file

@ -0,0 +1,15 @@
package com.stevesoltys.seedvault.ui
import android.content.Context
import android.text.format.DateUtils.MINUTE_IN_MILLIS
import android.text.format.DateUtils.getRelativeTimeSpanString
import com.stevesoltys.seedvault.R
fun Long.toRelativeTime(context: Context): CharSequence {
return if (this == 0L) {
context.getString(R.string.settings_backup_last_backup_never)
} else {
val now = System.currentTimeMillis()
getRelativeTimeSpanString(this, now, MINUTE_IN_MILLIS, 0)
}
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/textColorSecondary"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/yellow"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM4,12c0,-4.42 3.58,-8 8,-8 1.85,0 3.55,0.63 4.9,1.69L5.69,16.9C4.63,15.55 4,13.85 4,12zM12,20c-1.85,0 -3.55,-0.63 -4.9,-1.69L18.31,7.1C19.37,8.45 20,10.15 20,12c0,4.42 -3.58,8 -8,8z" />
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/textColorSecondary"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM10,17l-3.5,-3.5 1.41,-1.41L10,14.17 15.18,9l1.41,1.41L10,17z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="@color/yellow"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
</vector>

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
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"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -14,17 +14,19 @@
<string name="settings_backup_location_none">None</string> <string name="settings_backup_location_none">None</string>
<string name="settings_backup_location_internal">Internal Storage</string> <string name="settings_backup_location_internal">Internal Storage</string>
<string name="settings_backup_last_backup_never">Never</string> <string name="settings_backup_last_backup_never">Never</string>
<string name="settings_backup_location_summary">%1$s · Last Backup %2$s</string>
<string name="settings_info">All backups are encrypted on your phone. To restore from backup you will need your 12-word recovery code.</string> <string name="settings_info">All backups are encrypted on your phone. To restore from backup you will need your 12-word recovery code.</string>
<string name="settings_auto_restore_title">Automatic restore</string> <string name="settings_auto_restore_title">Automatic restore</string>
<string name="settings_auto_restore_summary">When reinstalling an app, restore backed up settings and data.</string> <string name="settings_auto_restore_summary">When reinstalling an app, restore backed up settings and data.</string>
<string name="settings_auto_restore_summary_usb">Note: Your %1$s needs to be plugged in for this to work.</string> <string name="settings_auto_restore_summary_usb">Note: Your %1$s needs to be plugged in for this to work.</string>
<string name="settings_category_app_data_backup">App data backup</string>
<string name="settings_backup_apk_title">App backup</string> <string name="settings_backup_apk_title">App backup</string>
<string name="settings_backup_apk_summary">Back up the apps themselves. Otherwise, only app data would get backed up.</string> <string name="settings_backup_apk_summary">Back up the apps themselves. Otherwise, only app data would get backed up.</string>
<string name="settings_backup_apk_dialog_title">Really disable app backup?</string> <string name="settings_backup_apk_dialog_title">Really disable app backup?</string>
<string name="settings_backup_apk_dialog_message">Disabled app backup will still back up app data. However, it will not get restored automatically.\n\nYou will need to install all your apps manually while having \"Automatic Restore\" switched on.</string> <string name="settings_backup_apk_dialog_message">Disabled app backup will still back up app data. However, it will not get restored automatically.\n\nYou will need to install all your apps manually while having \"Automatic Restore\" switched on.</string>
<string name="settings_backup_apk_dialog_cancel">Cancel</string> <string name="settings_backup_apk_dialog_cancel">Cancel</string>
<string name="settings_backup_apk_dialog_disable">Disable app backup</string> <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_now">Backup now</string> <string name="settings_backup_now">Backup now</string>
<!-- Storage --> <!-- Storage -->
@ -102,7 +104,7 @@
<string name="restore_restoring">Restoring Backup</string> <string name="restore_restoring">Restoring Backup</string>
<string name="restore_magic_package">System Package Manager</string> <string name="restore_magic_package">System Package Manager</string>
<string name="restore_app_no_data">App reported no data for backup</string> <string name="restore_app_no_data">App reported no data for backup</string>
<string name="restore_app_not_allowed">Data backup was not allowed</string> <string name="restore_app_not_allowed">App doesn\'t allow backup</string>
<string name="restore_app_not_installed">App not installed</string> <string name="restore_app_not_installed">App not installed</string>
<string name="restore_app_quota_exceeded">Backup quota exceeded</string> <string name="restore_app_quota_exceeded">Backup quota exceeded</string>
<string name="restore_finished_success">Restore complete</string> <string name="restore_finished_success">Restore complete</string>

View file

@ -1,4 +1,5 @@
<androidx.preference.PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"> <androidx.preference.PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.preference.SwitchPreferenceCompat <androidx.preference.SwitchPreferenceCompat
app:icon="@drawable/ic_cloud_upload" app:icon="@drawable/ic_cloud_upload"
@ -20,6 +21,17 @@
app:summary="@string/settings_auto_restore_summary" app:summary="@string/settings_auto_restore_summary"
app:title="@string/settings_auto_restore_title" /> app:title="@string/settings_auto_restore_title" />
<androidx.preference.PreferenceCategory
app:key="category_app_data_backup"
app:title="@string/settings_category_app_data_backup">
<androidx.preference.Preference
app:fragment="com.stevesoltys.seedvault.settings.AppStatusFragment"
app:icon="@drawable/ic_apps"
app:key="backup_status"
app:title="@string/settings_backup_status_title"
tools:summary="Last backup: Never" />
<androidx.preference.SwitchPreferenceCompat <androidx.preference.SwitchPreferenceCompat
app:defaultValue="true" app:defaultValue="true"
app:dependency="backup" app:dependency="backup"
@ -27,6 +39,8 @@
app:summary="@string/settings_backup_apk_summary" app:summary="@string/settings_backup_apk_summary"
app:title="@string/settings_backup_apk_title" /> app:title="@string/settings_backup_apk_title" />
</androidx.preference.PreferenceCategory>
<androidx.preference.Preference <androidx.preference.Preference
app:allowDividerAbove="true" app:allowDividerAbove="true"
app:allowDividerBelow="false" app:allowDividerBelow="false"

View file

@ -113,6 +113,7 @@ internal class BackupCoordinatorTest : BackupTest() {
val isFullBackup = Random.nextBoolean() val isFullBackup = Random.nextBoolean()
val quota = Random.nextLong() val quota = Random.nextLong()
expectApkBackupAndMetadataWrite()
if (isFullBackup) { if (isFullBackup) {
every { full.getQuota() } returns quota every { full.getQuota() } returns quota
} else { } else {
@ -264,9 +265,9 @@ internal class BackupCoordinatorTest : BackupTest() {
} }
private fun expectApkBackupAndMetadataWrite() { private fun expectApkBackupAndMetadataWrite() {
every { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns packageMetadata every { apkBackup.backupApkIfNecessary(any(), UNKNOWN_ERROR, any()) } returns packageMetadata
every { plugin.getMetadataOutputStream() } returns metadataOutputStream every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onApkBackedUp(packageInfo, packageMetadata, metadataOutputStream) } just Runs every { metadataManager.onApkBackedUp(any(), packageMetadata, metadataOutputStream) } just Runs
} }
} }