diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index 8daa8eac..4391067f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/App.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt @@ -201,6 +201,7 @@ open class App : Application() { const val MAGIC_PACKAGE_MANAGER: String = PACKAGE_MANAGER_SENTINEL const val ANCESTRAL_RECORD_KEY = "@ancestral_record@" +const val NO_DATA_END_SENTINEL = "@end@" const val GLOBAL_METADATA_KEY = "@meta@" const val ERROR_BACKUP_CANCELLED: Int = BackupManager.ERROR_BACKUP_CANCELLED diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/AppDataRestoreManager.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/AppDataRestoreManager.kt index de840750..88caefc0 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/AppDataRestoreManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/AppDataRestoreManager.kt @@ -20,7 +20,9 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.stevesoltys.seedvault.BackupMonitor import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER +import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL import com.stevesoltys.seedvault.R +import com.stevesoltys.seedvault.metadata.PackageMetadataMap import com.stevesoltys.seedvault.metadata.PackageState import com.stevesoltys.seedvault.restore.install.isInstalled 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.notification.getAppName import java.util.LinkedList +import java.util.Locale private val TAG = AppDataRestoreManager::class.simpleName +internal data class AppRestoreResult( + val packageName: String, + val name: String, + val state: AppBackupState, +) + internal class AppDataRestoreManager( private val context: Context, private val backupManager: IBackupManager, @@ -50,17 +59,17 @@ internal class AppDataRestoreManager( private var session: IRestoreSession? = null private val monitor = BackupMonitor() - private val mRestoreProgress = MutableLiveData>().apply { - value = LinkedList().apply { + private val mRestoreProgress = MutableLiveData( + LinkedList().apply { add( AppRestoreResult( packageName = MAGIC_PACKAGE_MANAGER, - name = getAppName(context, MAGIC_PACKAGE_MANAGER), - state = IN_PROGRESS + name = getAppName(context, MAGIC_PACKAGE_MANAGER).toString(), + state = IN_PROGRESS, ) ) } - } + ) val restoreProgress: LiveData> get() = mRestoreProgress private val mRestoreBackupResult = MutableLiveData() val restoreBackupResult: LiveData get() = mRestoreBackupResult @@ -88,13 +97,13 @@ internal class AppDataRestoreManager( return } - val packages = restorableBackup.packageMetadataMap.keys.toList() val observer = RestoreObserver( restoreCoordinator = restoreCoordinator, restorableBackup = restorableBackup, session = session, - packages = packages, - monitor = monitor + // sort packages (reverse) alphabetically, since we move from bottom to top + packages = restorableBackup.packageMetadataMap.packagesSortedByNameDescending, + monitor = monitor, ) // We need to retrieve the restore sets before starting the restore. @@ -128,9 +137,12 @@ internal class AppDataRestoreManager( updateLatestPackage(list, backup) // add current package - list.addFirst( - AppRestoreResult(packageName, getAppName(context, packageName), IN_PROGRESS) + val name = getAppName( + context = context, + packageName = packageName, + fallback = backup.packageMetadataMap[packageName]?.name?.toString() ?: packageName, ) + list.addFirst(AppRestoreResult(packageName, name.toString(), IN_PROGRESS)) mRestoreProgress.postValue(list) } @@ -167,14 +179,25 @@ internal class AppDataRestoreManager( // add missing packages as failed val seenPackages = list.map { it.packageName }.toSet() - val expectedPackages = backup.packageMetadataMap.keys + val expectedPackages = + backup.packageMetadataMap.packagesSortedByNameDescending.toMutableSet() expectedPackages.removeAll(seenPackages) - for (packageName: String in expectedPackages) { - // TODO don't add if it was a NO_DATA system app + for (packageName in expectedPackages) { val failedStatus = getFailedStatus(packageName, backup) - val appResult = - AppRestoreResult(packageName, getAppName(context, packageName), failedStatus) - list.addFirst(appResult) + if (failedStatus == FAILED_NO_DATA && + backup.packageMetadataMap[packageName]?.isInternalSystem == true + ) { + // 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) @@ -186,7 +209,6 @@ internal class AppDataRestoreManager( session = null } - // TODO sort apps alphabetically @WorkerThread private inner class RestoreObserver( private val restoreCoordinator: RestoreCoordinator, @@ -244,6 +266,7 @@ internal class AppDataRestoreManager( val token = backupMetadata.token val result = session.restorePackages(token, this, packageChunk, monitor) + @Suppress("UNRESOLVED_REFERENCE") // BackupManager.SUCCESS if (result != BackupManager.SUCCESS) { Log.e(TAG, "restorePackages() returned non-zero value: $result") } @@ -295,6 +318,7 @@ internal class AppDataRestoreManager( } private fun getRestoreResult(): RestoreBackupResult { + @Suppress("UNRESOLVED_REFERENCE") // BackupManager.SUCCESS val failedChunks = chunkResults .filter { it.value != BackupManager.SUCCESS } .map { "chunk ${it.key} failed with error ${it.value}" } @@ -310,4 +334,15 @@ internal class AppDataRestoreManager( } } } + + private val PackageMetadataMap.packagesSortedByNameDescending: List + 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 + } + } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt index ae235703..fff602d3 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressAdapter.kt @@ -6,21 +6,40 @@ package com.stevesoltys.seedvault.restore import android.content.pm.PackageManager.NameNotFoundException +import android.graphics.drawable.Drawable import android.view.LayoutInflater import android.view.View 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 com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.restore.RestoreProgressAdapter.PackageViewHolder -import com.stevesoltys.seedvault.ui.AppBackupState import com.stevesoltys.seedvault.ui.AppViewHolder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import java.util.LinkedList -internal class RestoreProgressAdapter : Adapter() { +internal class RestoreProgressAdapter( + val scope: CoroutineScope, + val iconLoader: suspend (AppRestoreResult, (Drawable) -> Unit) -> Unit, +) : Adapter() { - private val items = LinkedList() + private val diffCallback = object : ItemCallback() { + 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 { val v = LayoutInflater.from(parent.context) @@ -28,37 +47,24 @@ internal class RestoreProgressAdapter : Adapter() { return PackageViewHolder(v) } - override fun getItemCount() = items.size + override fun getItemCount() = differ.currentList.size override fun onBindViewHolder(holder: PackageViewHolder, position: Int) { - holder.bind(items[position]) + holder.bind(differ.currentList[position]) } - fun update(newItems: LinkedList) { - val diffResult = DiffUtil.calculateDiff(Diff(items, newItems)) - items.clear() - items.addAll(newItems) - diffResult.dispatchUpdatesTo(this) + fun update(newItems: LinkedList, callback: Runnable) { + // add .toList(), because [AppDataRestoreManager] still re-uses the same list, + // but AsyncListDiffer needs a new one. + differ.submitList(newItems.toList(), callback) } - private class Diff( - private val oldItems: LinkedList, - private val newItems: LinkedList, - ) : 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] - } + override fun onViewRecycled(holder: PackageViewHolder) { + holder.iconJob?.cancel() } - class PackageViewHolder(v: View) : AppViewHolder(v) { + inner class PackageViewHolder(v: View) : AppViewHolder(v) { + var iconJob: Job? = null fun bind(item: AppRestoreResult) { appName.text = item.name if (item.packageName == MAGIC_PACKAGE_MANAGER) { @@ -67,7 +73,11 @@ internal class RestoreProgressAdapter : Adapter() { try { appIcon.setImageDrawable(pm.getApplicationIcon(item.packageName)) } catch (e: NameNotFoundException) { - appIcon.setImageResource(R.drawable.ic_launcher_default) + iconJob = scope.launch { + iconLoader(item) { bitmap -> + appIcon.setImageDrawable(bitmap) + } + } } } setState(item.state, true) @@ -75,9 +85,3 @@ internal class RestoreProgressAdapter : Adapter() { } } - -internal data class AppRestoreResult( - val packageName: String, - val name: CharSequence, - val state: AppBackupState, -) diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt index d6085843..21b2f228 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt @@ -5,6 +5,7 @@ package com.stevesoltys.seedvault.restore +import android.graphics.drawable.Drawable import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -16,6 +17,7 @@ import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat.getColor import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.stevesoltys.seedvault.R @@ -27,7 +29,7 @@ class RestoreProgressFragment : Fragment() { private val viewModel: RestoreViewModel by sharedViewModel() private val layoutManager = LinearLayoutManager(context) - private val adapter = RestoreProgressAdapter() + private val adapter = RestoreProgressAdapter(lifecycleScope, this::loadIcon) private lateinit var progressBar: ProgressBar 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 requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON) - viewModel.chosenRestorableBackup.observe(viewLifecycleOwner, { restorableBackup -> + viewModel.chosenRestorableBackup.observe(viewLifecycleOwner) { restorableBackup -> backupNameView.text = restorableBackup.name progressBar.max = restorableBackup.packageMetadataMap.size - }) + } - viewModel.restoreProgress.observe(viewLifecycleOwner, { list -> - stayScrolledAtTop { adapter.update(list) } + viewModel.restoreProgress.observe(viewLifecycleOwner) { list -> 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 if (finished.hasError()) { backupNameView.text = finished.errorMsg @@ -87,7 +92,7 @@ class RestoreProgressFragment : Fragment() { onRestoreFinished() } activity?.window?.clearFlags(FLAG_KEEP_SCREEN_ON) - }) + } } private fun onRestoreFinished() { @@ -103,10 +108,8 @@ class RestoreProgressFragment : Fragment() { .show() } - private fun stayScrolledAtTop(add: () -> Unit) { - val position = layoutManager.findFirstVisibleItemPosition() - add.invoke() - if (position == 0) layoutManager.scrollToPosition(0) + private suspend fun loadIcon(item: AppRestoreResult, callback: (Drawable) -> Unit) { + viewModel.loadIcon(item.packageName, callback) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt index 2bbb08dc..d45dca91 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt @@ -167,14 +167,18 @@ internal class NotificationBackupObserver( } -fun getAppName(context: Context, packageId: String): CharSequence { - if (packageId == MAGIC_PACKAGE_MANAGER || packageId.startsWith("@")) { +fun getAppName( + context: Context, + packageName: String, + fallback: String = packageName, +): CharSequence { + if (packageName == MAGIC_PACKAGE_MANAGER || packageName.startsWith("@")) { return context.getString(R.string.restore_magic_package) } return try { - val appInfo = context.packageManager.getApplicationInfo(packageId, 0) + val appInfo = context.packageManager.getApplicationInfo(packageName, 0) context.packageManager.getApplicationLabel(appInfo) } catch (e: NameNotFoundException) { - packageId + fallback } }