Improve app data restore process

Apps are now restored alphabetically to be consistent with the other lists. Some irrelevant apps are hidden. Under the hood, we now use an AsyncListDiffer like in the other lists.
This commit is contained in:
Torsten Grote 2024-05-31 16:48:27 -03:00
parent b3f93adf77
commit fa19261d8e
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
5 changed files with 114 additions and 67 deletions

View file

@ -201,6 +201,7 @@ open class App : Application() {
const val MAGIC_PACKAGE_MANAGER: String = PACKAGE_MANAGER_SENTINEL const val MAGIC_PACKAGE_MANAGER: String = PACKAGE_MANAGER_SENTINEL
const val ANCESTRAL_RECORD_KEY = "@ancestral_record@" const val ANCESTRAL_RECORD_KEY = "@ancestral_record@"
const val NO_DATA_END_SENTINEL = "@end@"
const val GLOBAL_METADATA_KEY = "@meta@" const val GLOBAL_METADATA_KEY = "@meta@"
const val ERROR_BACKUP_CANCELLED: Int = BackupManager.ERROR_BACKUP_CANCELLED const val ERROR_BACKUP_CANCELLED: Int = BackupManager.ERROR_BACKUP_CANCELLED

View file

@ -20,7 +20,9 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.stevesoltys.seedvault.BackupMonitor import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.metadata.PackageState import com.stevesoltys.seedvault.metadata.PackageState
import com.stevesoltys.seedvault.restore.install.isInstalled import com.stevesoltys.seedvault.restore.install.isInstalled
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
@ -37,9 +39,16 @@ import com.stevesoltys.seedvault.ui.AppBackupState.NOT_YET_BACKED_UP
import com.stevesoltys.seedvault.ui.AppBackupState.SUCCEEDED import com.stevesoltys.seedvault.ui.AppBackupState.SUCCEEDED
import com.stevesoltys.seedvault.ui.notification.getAppName import com.stevesoltys.seedvault.ui.notification.getAppName
import java.util.LinkedList import java.util.LinkedList
import java.util.Locale
private val TAG = AppDataRestoreManager::class.simpleName private val TAG = AppDataRestoreManager::class.simpleName
internal data class AppRestoreResult(
val packageName: String,
val name: String,
val state: AppBackupState,
)
internal class AppDataRestoreManager( internal class AppDataRestoreManager(
private val context: Context, private val context: Context,
private val backupManager: IBackupManager, private val backupManager: IBackupManager,
@ -50,17 +59,17 @@ internal class AppDataRestoreManager(
private var session: IRestoreSession? = null private var session: IRestoreSession? = null
private val monitor = BackupMonitor() private val monitor = BackupMonitor()
private val mRestoreProgress = MutableLiveData<LinkedList<AppRestoreResult>>().apply { private val mRestoreProgress = MutableLiveData(
value = LinkedList<AppRestoreResult>().apply { LinkedList<AppRestoreResult>().apply {
add( add(
AppRestoreResult( AppRestoreResult(
packageName = MAGIC_PACKAGE_MANAGER, packageName = MAGIC_PACKAGE_MANAGER,
name = getAppName(context, MAGIC_PACKAGE_MANAGER), name = getAppName(context, MAGIC_PACKAGE_MANAGER).toString(),
state = IN_PROGRESS state = IN_PROGRESS,
) )
) )
} }
} )
val restoreProgress: LiveData<LinkedList<AppRestoreResult>> get() = mRestoreProgress val restoreProgress: LiveData<LinkedList<AppRestoreResult>> get() = mRestoreProgress
private val mRestoreBackupResult = MutableLiveData<RestoreBackupResult>() private val mRestoreBackupResult = MutableLiveData<RestoreBackupResult>()
val restoreBackupResult: LiveData<RestoreBackupResult> get() = mRestoreBackupResult val restoreBackupResult: LiveData<RestoreBackupResult> get() = mRestoreBackupResult
@ -88,13 +97,13 @@ internal class AppDataRestoreManager(
return return
} }
val packages = restorableBackup.packageMetadataMap.keys.toList()
val observer = RestoreObserver( val observer = RestoreObserver(
restoreCoordinator = restoreCoordinator, restoreCoordinator = restoreCoordinator,
restorableBackup = restorableBackup, restorableBackup = restorableBackup,
session = session, session = session,
packages = packages, // sort packages (reverse) alphabetically, since we move from bottom to top
monitor = monitor packages = restorableBackup.packageMetadataMap.packagesSortedByNameDescending,
monitor = monitor,
) )
// We need to retrieve the restore sets before starting the restore. // We need to retrieve the restore sets before starting the restore.
@ -128,9 +137,12 @@ internal class AppDataRestoreManager(
updateLatestPackage(list, backup) updateLatestPackage(list, backup)
// add current package // add current package
list.addFirst( val name = getAppName(
AppRestoreResult(packageName, getAppName(context, packageName), IN_PROGRESS) context = context,
packageName = packageName,
fallback = backup.packageMetadataMap[packageName]?.name?.toString() ?: packageName,
) )
list.addFirst(AppRestoreResult(packageName, name.toString(), IN_PROGRESS))
mRestoreProgress.postValue(list) mRestoreProgress.postValue(list)
} }
@ -167,14 +179,25 @@ internal class AppDataRestoreManager(
// add missing packages as failed // add missing packages as failed
val seenPackages = list.map { it.packageName }.toSet() val seenPackages = list.map { it.packageName }.toSet()
val expectedPackages = backup.packageMetadataMap.keys val expectedPackages =
backup.packageMetadataMap.packagesSortedByNameDescending.toMutableSet()
expectedPackages.removeAll(seenPackages) expectedPackages.removeAll(seenPackages)
for (packageName: String in expectedPackages) { for (packageName in expectedPackages) {
// TODO don't add if it was a NO_DATA system app
val failedStatus = getFailedStatus(packageName, backup) val failedStatus = getFailedStatus(packageName, backup)
val appResult = if (failedStatus == FAILED_NO_DATA &&
AppRestoreResult(packageName, getAppName(context, packageName), failedStatus) backup.packageMetadataMap[packageName]?.isInternalSystem == true
list.addFirst(appResult) ) {
// don't add internal system apps that had NO_DATA to backup
} else {
val name = getAppName(
context = context,
packageName = packageName,
fallback = backup.packageMetadataMap[packageName]?.name?.toString()
?: packageName,
)
val appResult = AppRestoreResult(packageName, name.toString(), failedStatus)
list.addFirst(appResult)
}
} }
mRestoreProgress.postValue(list) mRestoreProgress.postValue(list)
@ -186,7 +209,6 @@ internal class AppDataRestoreManager(
session = null session = null
} }
// TODO sort apps alphabetically
@WorkerThread @WorkerThread
private inner class RestoreObserver( private inner class RestoreObserver(
private val restoreCoordinator: RestoreCoordinator, private val restoreCoordinator: RestoreCoordinator,
@ -244,6 +266,7 @@ internal class AppDataRestoreManager(
val token = backupMetadata.token val token = backupMetadata.token
val result = session.restorePackages(token, this, packageChunk, monitor) val result = session.restorePackages(token, this, packageChunk, monitor)
@Suppress("UNRESOLVED_REFERENCE") // BackupManager.SUCCESS
if (result != BackupManager.SUCCESS) { if (result != BackupManager.SUCCESS) {
Log.e(TAG, "restorePackages() returned non-zero value: $result") Log.e(TAG, "restorePackages() returned non-zero value: $result")
} }
@ -295,6 +318,7 @@ internal class AppDataRestoreManager(
} }
private fun getRestoreResult(): RestoreBackupResult { private fun getRestoreResult(): RestoreBackupResult {
@Suppress("UNRESOLVED_REFERENCE") // BackupManager.SUCCESS
val failedChunks = chunkResults val failedChunks = chunkResults
.filter { it.value != BackupManager.SUCCESS } .filter { it.value != BackupManager.SUCCESS }
.map { "chunk ${it.key} failed with error ${it.value}" } .map { "chunk ${it.key} failed with error ${it.value}" }
@ -310,4 +334,15 @@ internal class AppDataRestoreManager(
} }
} }
} }
private val PackageMetadataMap.packagesSortedByNameDescending: List<String>
get() {
return asIterable().sortedByDescending { (packageName, metadata) ->
// sort packages (reverse) alphabetically, since we move from bottom to top
(metadata.name?.toString() ?: packageName).lowercase(Locale.getDefault())
}.mapNotNull {
// don't try to restore this helper package, as it doesn't really exist
if (it.key == NO_DATA_END_SENTINEL) null else it.key
}
}
} }

View file

@ -6,21 +6,40 @@
package com.stevesoltys.seedvault.restore package com.stevesoltys.seedvault.restore
import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.PackageManager.NameNotFoundException
import android.graphics.drawable.Drawable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil.ItemCallback
import androidx.recyclerview.widget.RecyclerView.Adapter import androidx.recyclerview.widget.RecyclerView.Adapter
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.RestoreProgressAdapter.PackageViewHolder import com.stevesoltys.seedvault.restore.RestoreProgressAdapter.PackageViewHolder
import com.stevesoltys.seedvault.ui.AppBackupState
import com.stevesoltys.seedvault.ui.AppViewHolder import com.stevesoltys.seedvault.ui.AppViewHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.LinkedList import java.util.LinkedList
internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() { internal class RestoreProgressAdapter(
val scope: CoroutineScope,
val iconLoader: suspend (AppRestoreResult, (Drawable) -> Unit) -> Unit,
) : Adapter<PackageViewHolder>() {
private val items = LinkedList<AppRestoreResult>() private val diffCallback = object : ItemCallback<AppRestoreResult>() {
override fun areItemsTheSame(
oldItem: AppRestoreResult,
newItem: AppRestoreResult,
): Boolean {
return oldItem.packageName == newItem.packageName
}
override fun areContentsTheSame(old: AppRestoreResult, new: AppRestoreResult): Boolean {
return old.name == new.name && old.state == new.state
}
}
private val differ = AsyncListDiffer(this, diffCallback)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PackageViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PackageViewHolder {
val v = LayoutInflater.from(parent.context) val v = LayoutInflater.from(parent.context)
@ -28,37 +47,24 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
return PackageViewHolder(v) return PackageViewHolder(v)
} }
override fun getItemCount() = items.size override fun getItemCount() = differ.currentList.size
override fun onBindViewHolder(holder: PackageViewHolder, position: Int) { override fun onBindViewHolder(holder: PackageViewHolder, position: Int) {
holder.bind(items[position]) holder.bind(differ.currentList[position])
} }
fun update(newItems: LinkedList<AppRestoreResult>) { fun update(newItems: LinkedList<AppRestoreResult>, callback: Runnable) {
val diffResult = DiffUtil.calculateDiff(Diff(items, newItems)) // add .toList(), because [AppDataRestoreManager] still re-uses the same list,
items.clear() // but AsyncListDiffer needs a new one.
items.addAll(newItems) differ.submitList(newItems.toList(), callback)
diffResult.dispatchUpdatesTo(this)
} }
private class Diff( override fun onViewRecycled(holder: PackageViewHolder) {
private val oldItems: LinkedList<AppRestoreResult>, holder.iconJob?.cancel()
private val newItems: LinkedList<AppRestoreResult>,
) : 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]
}
} }
class PackageViewHolder(v: View) : AppViewHolder(v) { inner class PackageViewHolder(v: View) : AppViewHolder(v) {
var iconJob: Job? = null
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) {
@ -67,7 +73,11 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
try { try {
appIcon.setImageDrawable(pm.getApplicationIcon(item.packageName)) appIcon.setImageDrawable(pm.getApplicationIcon(item.packageName))
} catch (e: NameNotFoundException) { } catch (e: NameNotFoundException) {
appIcon.setImageResource(R.drawable.ic_launcher_default) iconJob = scope.launch {
iconLoader(item) { bitmap ->
appIcon.setImageDrawable(bitmap)
}
}
} }
} }
setState(item.state, true) setState(item.state, true)
@ -75,9 +85,3 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
} }
} }
internal data class AppRestoreResult(
val packageName: String,
val name: CharSequence,
val state: AppBackupState,
)

View file

@ -5,6 +5,7 @@
package com.stevesoltys.seedvault.restore package com.stevesoltys.seedvault.restore
import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -16,6 +17,7 @@ import android.widget.TextView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat.getColor import androidx.core.content.ContextCompat.getColor
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
@ -27,7 +29,7 @@ class RestoreProgressFragment : Fragment() {
private val viewModel: RestoreViewModel by sharedViewModel() private val viewModel: RestoreViewModel by sharedViewModel()
private val layoutManager = LinearLayoutManager(context) private val layoutManager = LinearLayoutManager(context)
private val adapter = RestoreProgressAdapter() private val adapter = RestoreProgressAdapter(lifecycleScope, this::loadIcon)
private lateinit var progressBar: ProgressBar private lateinit var progressBar: ProgressBar
private lateinit var titleView: TextView private lateinit var titleView: TextView
@ -67,17 +69,20 @@ class RestoreProgressFragment : Fragment() {
// decryption will fail when the device is locked, so keep the screen on to prevent locking // decryption will fail when the device is locked, so keep the screen on to prevent locking
requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON) requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON)
viewModel.chosenRestorableBackup.observe(viewLifecycleOwner, { restorableBackup -> viewModel.chosenRestorableBackup.observe(viewLifecycleOwner) { restorableBackup ->
backupNameView.text = restorableBackup.name backupNameView.text = restorableBackup.name
progressBar.max = restorableBackup.packageMetadataMap.size progressBar.max = restorableBackup.packageMetadataMap.size
}) }
viewModel.restoreProgress.observe(viewLifecycleOwner, { list -> viewModel.restoreProgress.observe(viewLifecycleOwner) { list ->
stayScrolledAtTop { adapter.update(list) }
progressBar.progress = list.size progressBar.progress = list.size
}) val position = layoutManager.findFirstVisibleItemPosition()
adapter.update(list) {
if (position == 0) layoutManager.scrollToPosition(0)
}
}
viewModel.restoreBackupResult.observe(viewLifecycleOwner, { finished -> viewModel.restoreBackupResult.observe(viewLifecycleOwner) { finished ->
button.isEnabled = true button.isEnabled = true
if (finished.hasError()) { if (finished.hasError()) {
backupNameView.text = finished.errorMsg backupNameView.text = finished.errorMsg
@ -87,7 +92,7 @@ class RestoreProgressFragment : Fragment() {
onRestoreFinished() onRestoreFinished()
} }
activity?.window?.clearFlags(FLAG_KEEP_SCREEN_ON) activity?.window?.clearFlags(FLAG_KEEP_SCREEN_ON)
}) }
} }
private fun onRestoreFinished() { private fun onRestoreFinished() {
@ -103,10 +108,8 @@ class RestoreProgressFragment : Fragment() {
.show() .show()
} }
private fun stayScrolledAtTop(add: () -> Unit) { private suspend fun loadIcon(item: AppRestoreResult, callback: (Drawable) -> Unit) {
val position = layoutManager.findFirstVisibleItemPosition() viewModel.loadIcon(item.packageName, callback)
add.invoke()
if (position == 0) layoutManager.scrollToPosition(0)
} }
} }

View file

@ -167,14 +167,18 @@ internal class NotificationBackupObserver(
} }
fun getAppName(context: Context, packageId: String): CharSequence { fun getAppName(
if (packageId == MAGIC_PACKAGE_MANAGER || packageId.startsWith("@")) { context: Context,
packageName: String,
fallback: String = packageName,
): CharSequence {
if (packageName == MAGIC_PACKAGE_MANAGER || packageName.startsWith("@")) {
return context.getString(R.string.restore_magic_package) return context.getString(R.string.restore_magic_package)
} }
return try { return try {
val appInfo = context.packageManager.getApplicationInfo(packageId, 0) val appInfo = context.packageManager.getApplicationInfo(packageName, 0)
context.packageManager.getApplicationLabel(appInfo) context.packageManager.getApplicationLabel(appInfo)
} catch (e: NameNotFoundException) { } catch (e: NameNotFoundException) {
packageId fallback
} }
} }