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
sourceCompatibility 1.8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
testOptions {
unitTests.all {
useJUnitPlatform()

View file

@ -7,7 +7,6 @@ import android.app.NotificationManager.IMPORTANCE_HIGH
import android.app.NotificationManager.IMPORTANCE_LOW
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.content.Context
import android.content.Intent
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.EXTRA_PACKAGE_NAME
import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL
import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST
import com.stevesoltys.seedvault.settings.SettingsActivity
private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver"
@ -78,23 +78,6 @@ class BackupNotificationManager(private val context: Context) {
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) {
if (!userInitiated) {
nm.cancel(NOTIFICATION_ID_OBSERVER)
@ -104,11 +87,19 @@ class BackupNotificationManager(private val context: Context) {
val contentText = if (notBackedUp == null) null else {
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 {
setContentTitle(context.getString(titleRes))
setContentText(contentText)
setOngoing(false)
setShowWhen(true)
setAutoCancel(true)
setSmallIcon(iconRes)
setContentIntent(pendingIntent)
setWhen(System.currentTimeMillis())
setProgress(0, 0, false)
priority = PRIORITY_LOW

View file

@ -1,6 +1,5 @@
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.ENCRYPTION_PADDING_NONE
import android.security.keystore.KeyProperties.PURPOSE_DECRYPT
@ -48,7 +47,7 @@ internal class KeyManagerImpl : KeyManager {
override fun storeBackupKey(seed: ByteArray) {
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 ksEntry = SecretKeyEntry(secretKeySpec)
keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection())
@ -68,7 +67,7 @@ internal class KeyManagerImpl : KeyManager {
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
// unlocking is required only for decryption, so when restoring from backup
if (SDK_INT >= 28) builder.setUnlockedDeviceRequired(true)
builder.setUnlockedDeviceRequired(true)
return builder.build()
}

View file

@ -8,6 +8,9 @@ import android.content.pm.PackageInfo
import android.util.Log
import androidx.annotation.VisibleForTesting
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.MAGIC_PACKAGE_MANAGER
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.
throw AssertionError("Error reading metadata from cache", e)
}
mLastBackupTime.postValue(field.time)
}
return field
}
@ -155,6 +159,7 @@ class MetadataManager(
metadata = oldMetadata
throw IOException(e)
}
mLastBackupTime.postValue(metadata.time)
}
/**
@ -171,7 +176,10 @@ class MetadataManager(
* Note that this might be a blocking I/O call.
*/
@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
fun getPackageMetadata(packageName: String): PackageMetadata? {

View file

@ -5,11 +5,7 @@ import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
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.ViewHolder
import androidx.recyclerview.widget.SortedList
import androidx.recyclerview.widget.SortedListAdapterCallback
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.QUEUED
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) {
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 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)
return AppViewHolder(v)
return AppInstallViewHolder(v)
}
override fun getItemCount() = items.size()
override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
override fun onBindViewHolder(holder: AppInstallViewHolder, position: Int) {
holder.bind(items[position])
}
@ -43,12 +40,7 @@ internal class InstallProgressAdapter : Adapter<AppViewHolder>() {
}
}
internal class AppViewHolder(v: View) : ViewHolder(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)
internal class AppInstallViewHolder(v: View) : AppViewHolder(v) {
fun bind(item: ApkRestoreResult) {
appIcon.setImageDrawable(item.icon)

View file

@ -3,26 +3,13 @@ package com.stevesoltys.seedvault.restore
import android.content.pm.PackageManager.NameNotFoundException
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 android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
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.ui.AppViewHolder
import java.util.*
internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
@ -63,16 +50,7 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
}
}
inner class PackageViewHolder(v: View) : ViewHolder(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)
inner class PackageViewHolder(v: View) : AppViewHolder(v) {
fun bind(item: AppRestoreResult) {
appName.text = item.name
if (item.packageName == MAGIC_PACKAGE_MANAGER) {
@ -84,38 +62,8 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
appIcon.setImageResource(R.drawable.ic_launcher_default)
}
}
if (item.status == IN_PROGRESS) {
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
}
}
}
setStatus(item.status)
}
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 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.R
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.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 notificationManager: BackupNotificationManager by inject()
@ -23,7 +28,11 @@ class SettingsActivity : RequireProvisioningActivity() {
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) showFragment(SettingsFragment())
if (intent?.action == ACTION_APP_STATUS_LIST) {
showFragment(AppStatusFragment())
} else if (savedInstanceState == null) {
showFragment(SettingsFragment())
}
}
@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.provider.Settings
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.view.Menu
import android.view.MenuInflater
@ -28,6 +26,7 @@ import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.UsbMonitor
import com.stevesoltys.seedvault.isMassStorage
import com.stevesoltys.seedvault.restore.RestoreActivity
import com.stevesoltys.seedvault.ui.toRelativeTime
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
@ -43,6 +42,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var autoRestore: TwoStatePreference
private lateinit var apkBackup: TwoStatePreference
private lateinit var backupLocation: Preference
private lateinit var backupStatus: Preference
private var menuBackupNow: MenuItem? = null
private var menuRestore: MenuItem? = null
@ -65,7 +65,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
setPreferencesFromResource(R.xml.settings, rootKey)
setHasOptionsMenu(true)
backup = findPreference<TwoStatePreference>("backup")!!
backup = findPreference("backup")!!
backup.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean
try {
@ -78,13 +78,13 @@ class SettingsFragment : PreferenceFragmentCompat() {
}
}
backupLocation = findPreference<Preference>("backup_location")!!
backupLocation = findPreference("backup_location")!!
backupLocation.setOnPreferenceClickListener {
viewModel.chooseBackupLocation()
true
}
autoRestore = findPreference<TwoStatePreference>("auto_restore")!!
autoRestore = findPreference("auto_restore")!!
autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val enabled = newValue as Boolean
try {
@ -115,8 +115,9 @@ class SettingsFragment : PreferenceFragmentCompat() {
.show()
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() {
@ -126,10 +127,10 @@ class SettingsFragment : PreferenceFragmentCompat() {
activity?.setTitle(R.string.backup)
storage = settingsManager.getStorage()
setBackupState()
setBackupEnabledState()
setBackupLocationSummary()
setAutoRestoreState()
setMenuItemStates()
viewModel.updateLastBackupTime()
if (storage?.isUsb == true) context?.registerReceiver(usbReceiver, usbFilter)
}
@ -150,23 +151,23 @@ class SettingsFragment : PreferenceFragmentCompat() {
setMenuItemStates()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when {
item.itemId == R.id.action_backup -> {
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.action_backup -> {
viewModel.backupNow()
true
}
item.itemId == R.id.action_restore -> {
R.id.action_restore -> {
startActivity(Intent(requireContext(), RestoreActivity::class.java))
true
}
item.itemId == R.id.action_about -> {
R.id.action_about -> {
AboutDialogFragment().show(fragmentManager!!, AboutDialogFragment.TAG)
true
}
else -> super.onOptionsItemSelected(item)
}
private fun setBackupState() {
private fun setBackupEnabledState() {
try {
backup.isChecked = backupManager.isBackupEnabled
backup.isEnabled = true
@ -189,17 +190,15 @@ class SettingsFragment : PreferenceFragmentCompat() {
}
}
private fun setBackupLocationSummary(lastBackupInMillis: Long) {
private fun setBackupLocationSummary() {
// 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)
}
private fun setAppBackupStatusSummary(lastBackupInMillis: Long) {
// 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)
val lastBackup = lastBackupInMillis.toRelativeTime(requireContext())
backupStatus.summary = getString(R.string.settings_backup_status_summary, lastBackup)
}
private fun setMenuItemStates() {

View file

@ -1,12 +1,37 @@
package com.stevesoltys.seedvault.settings
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.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.getAppName
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.ui.RequireProvisioningViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
private val TAG = SettingsViewModel::class.java.simpleName
class SettingsViewModel(
app: Application,
@ -17,15 +42,64 @@ class SettingsViewModel(
override val isRestoreOperation = false
private val _lastBackupTime = MutableLiveData<Long>()
internal val lastBackupTime: LiveData<Long> = _lastBackupTime
internal val lastBackupTime = metadataManager.lastBackupTime
internal fun updateLastBackupTime() {
Thread { _lastBackupTime.postValue(metadataManager.getLastBackupTime()) }.start()
private val mAppStatusList = switchMap(lastBackupTime) { getAppStatusResult() }
internal val appStatusList: LiveData<AppStatusResult> = mAppStatusList
init {
viewModelScope.launch(Dispatchers.IO) {
// ensures the lastBackupTime LiveData gets set
metadataManager.getLastBackupTime()
}
}
internal fun backupNow() {
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.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.seedvault.BackupNotificationManager
@ -94,7 +95,20 @@ internal class BackupCoordinator(
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 {
// 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.")
val quota = if (isFullBackup) full.getQuota() else kv.getQuota()
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
backUpNotAllowedPackages()
}
val result = kv.performBackup(packageInfo, data, flags)
return backUpApk(result, packageInfo)
return kv.performBackup(packageInfo, data, flags)
}
// ------------------------------------------------------------------------------------
@ -166,8 +179,7 @@ internal class BackupCoordinator(
fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int): Int {
cancelReason = UNKNOWN_ERROR
val result = full.performFullBackup(targetPackage, fileDescriptor, flags)
return backUpApk(result, targetPackage)
return full.performFullBackup(targetPackage, fileDescriptor, flags)
}
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...")
packageService.notAllowedPackages.forEach { optOutPackageInfo ->
try {
backUpApk(0, optOutPackageInfo, NOT_ALLOWED)
backUpApk(optOutPackageInfo, NOT_ALLOWED)
} catch (e: IOException) {
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
if (packageName == MAGIC_PACKAGE_MANAGER) return result
return try {
try {
apkBackup.backupApkIfNecessary(packageInfo, packageState) {
plugin.getApkOutputStream(packageInfo)
}?.let { packageMetadata ->
val outputStream = plugin.getMetadataOutputStream()
metadataManager.onApkBackedUp(packageInfo, packageMetadata, outputStream)
}
result
} catch (e: IOException) {
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_internal">Internal Storage</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_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_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_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_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_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>
<!-- Storage -->
@ -102,7 +104,7 @@
<string name="restore_restoring">Restoring Backup</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_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_quota_exceeded">Backup quota exceeded</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
app:icon="@drawable/ic_cloud_upload"
@ -20,12 +21,25 @@
app:summary="@string/settings_auto_restore_summary"
app:title="@string/settings_auto_restore_title" />
<androidx.preference.SwitchPreferenceCompat
app:defaultValue="true"
app:dependency="backup"
app:key="backup_apk"
app:summary="@string/settings_backup_apk_summary"
app:title="@string/settings_backup_apk_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
app:defaultValue="true"
app:dependency="backup"
app:key="backup_apk"
app:summary="@string/settings_backup_apk_summary"
app:title="@string/settings_backup_apk_title" />
</androidx.preference.PreferenceCategory>
<androidx.preference.Preference
app:allowDividerAbove="true"

View file

@ -113,6 +113,7 @@ internal class BackupCoordinatorTest : BackupTest() {
val isFullBackup = Random.nextBoolean()
val quota = Random.nextLong()
expectApkBackupAndMetadataWrite()
if (isFullBackup) {
every { full.getQuota() } returns quota
} else {
@ -264,9 +265,9 @@ internal class BackupCoordinatorTest : BackupTest() {
}
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 { metadataManager.onApkBackedUp(packageInfo, packageMetadata, metadataOutputStream) } just Runs
every { metadataManager.onApkBackedUp(any(), packageMetadata, metadataOutputStream) } just Runs
}
}