From 3f50b3578339e4c3a8e52f1b70daac9ae4819e1c Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 8 Oct 2020 17:19:20 -0300 Subject: [PATCH] Allow the user to manually re-install apps before data restore starts When one or more apps fail to install, the user is shown a dialog explaining that we need the apps installed in order for restore to work. After the dialog is dismissed, the list of apps is resorted so failed apps are at the top. They are made clickable and the user is brought to an app store to re-install them. --- .../seedvault/restore/RestoreViewModel.kt | 27 +++-- .../seedvault/restore/install/ApkInstaller.kt | 19 ++- .../seedvault/restore/install/ApkRestore.kt | 52 ++++---- .../restore/install/InstallProgressAdapter.kt | 87 ++++++++----- .../install/InstallProgressFragment.kt | 68 +++++++++-- .../restore/install/InstallResult.kt | 80 ++++++++---- app/src/main/res/values/strings.xml | 3 + .../restore/install/ApkRestoreTest.kt | 114 ++++++++++-------- 8 files changed, 293 insertions(+), 157 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt index 57c0711a..8a80aad2 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt @@ -15,7 +15,6 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations.switchMap import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope -import com.stevesoltys.seedvault.ui.AppBackupState import com.stevesoltys.seedvault.BackupMonitor import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.R @@ -27,6 +26,14 @@ 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.PackageState.WAS_STOPPED +import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS +import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP +import com.stevesoltys.seedvault.restore.install.ApkRestore +import com.stevesoltys.seedvault.restore.install.InstallResult +import com.stevesoltys.seedvault.settings.SettingsManager +import com.stevesoltys.seedvault.transport.TRANSPORT_ID +import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator +import com.stevesoltys.seedvault.ui.AppBackupState import com.stevesoltys.seedvault.ui.AppBackupState.FAILED import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_ALLOWED import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_INSTALLED @@ -35,20 +42,12 @@ import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_QUOTA_EXCEEDED import com.stevesoltys.seedvault.ui.AppBackupState.IN_PROGRESS import com.stevesoltys.seedvault.ui.AppBackupState.NOT_YET_BACKED_UP import com.stevesoltys.seedvault.ui.AppBackupState.SUCCEEDED -import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS -import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP -import com.stevesoltys.seedvault.restore.install.InstallResult -import com.stevesoltys.seedvault.settings.SettingsManager -import com.stevesoltys.seedvault.transport.TRANSPORT_ID -import com.stevesoltys.seedvault.restore.install.ApkRestore -import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator import com.stevesoltys.seedvault.ui.LiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.ui.notification.getAppName import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onCompletion @@ -87,7 +86,6 @@ internal class RestoreViewModel( internal val installResult: LiveData = switchMap(mChosenRestorableBackup) { backup -> - @Suppress("EXPERIMENTAL_API_USAGE") getInstallResult(backup) } @@ -151,8 +149,8 @@ internal class RestoreViewModel( closeSession() } - @ExperimentalCoroutinesApi private fun getInstallResult(restorableBackup: RestorableBackup): LiveData { + @Suppress("EXPERIMENTAL_API_USAGE") return apkRestore.restore(restorableBackup.token, restorableBackup.packageMetadataMap) .onStart { Log.d(TAG, "Start InstallResult Flow") @@ -163,7 +161,9 @@ internal class RestoreViewModel( mNextButtonEnabled.postValue(true) } .flowOn(ioDispatcher) - .asLiveData() + // collect on the same thread, so concurrency issues don't mess up live data updates + // e.g. InstallResult#isFinished isn't reported too early. + .asLiveData(ioDispatcher) } internal fun onNextClicked() { @@ -336,7 +336,8 @@ internal class RestoreViewModel( /** * The restore operation has begun. * - * @param numPackages The total number of packages being processed in this restore operation. + * @param numPackages The total number of packages + * being processed in this restore operation. */ override fun restoreStarting(numPackages: Int) { // noop diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkInstaller.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkInstaller.kt index 1e3c0372..c9ec4513 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkInstaller.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkInstaller.kt @@ -20,13 +20,12 @@ import android.util.Log import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import java.io.File import java.io.IOException +import kotlin.coroutines.resume private val TAG: String = ApkInstaller::class.java.simpleName @@ -37,26 +36,24 @@ internal class ApkInstaller(private val context: Context) { private val pm: PackageManager = context.packageManager private val installer: PackageInstaller = pm.packageInstaller - @ExperimentalCoroutinesApi @Throws(IOException::class, SecurityException::class) - internal fun install( + internal suspend fun install( cachedApk: File, packageName: String, installerPackageName: String?, installResult: MutableInstallResult - ) = callbackFlow { + ) = suspendCancellableCoroutine { cont -> val broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, i: Intent) { if (i.action != BROADCAST_ACTION) return - offer(onBroadcastReceived(i, packageName, cachedApk, installResult)) - close() + context.unregisterReceiver(this) + cont.resume(onBroadcastReceived(i, packageName, cachedApk, installResult)) } } context.registerReceiver(broadcastReceiver, IntentFilter(BROADCAST_ACTION)) + cont.invokeOnCancellation { context.unregisterReceiver(broadcastReceiver) } install(cachedApk, installerPackageName) - - awaitClose { context.unregisterReceiver(broadcastReceiver) } } private fun install(cachedApk: File, installerPackageName: String?) { @@ -65,7 +62,6 @@ internal class ApkInstaller(private val context: Context) { } // Don't set more sessionParams intentionally here. // We saw strange permission issues when doing setInstallReason() or setting installFlags. - @Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO val session = installer.openSession(installer.createSession(sessionParams)) val sizeBytes = cachedApk.length() session.use { s -> @@ -110,6 +106,7 @@ internal class ApkInstaller(private val context: Context) { } // update status and offer result + // TODO maybe don't back up statusMsg=INSTALL_FAILED_TEST_ONLY apps in the first place? val status = if (success) SUCCEEDED else FAILED return installResult.update(packageName) { it.copy(state = status) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt index 8acf3769..8932bb3b 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt @@ -14,9 +14,8 @@ import com.stevesoltys.seedvault.transport.backup.copyStreamsAndGetHash import com.stevesoltys.seedvault.transport.backup.getSignatures import com.stevesoltys.seedvault.transport.backup.isSystemApp import com.stevesoltys.seedvault.transport.restore.RestorePlugin -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.flow import java.io.File import java.io.IOException @@ -31,7 +30,6 @@ internal class ApkRestore( private val pm = context.packageManager - @ExperimentalCoroutinesApi fun restore(token: Long, packageMetadataMap: PackageMetadataMap) = flow { // filter out packages without APK and get total val packages = packageMetadataMap.filter { it.value.hasApk() } @@ -40,19 +38,21 @@ internal class ApkRestore( // queue all packages and emit LiveData val installResult = MutableInstallResult(total) - packages.forEach { (packageName, _) -> + packages.forEach { (packageName, metadata) -> progress++ - installResult[packageName] = ApkInstallResult(packageName, progress, total, QUEUED) + installResult[packageName] = ApkInstallResult( + packageName = packageName, + progress = progress, + state = QUEUED, + installerPackageName = metadata.installer + ) } emit(installResult) - // restore individual packages and emit updates + // re-install individual packages and emit updates for ((packageName, metadata) in packages) { try { - @Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO - restore(token, packageName, metadata, installResult).collect { - emit(it) - } + restore(this, token, packageName, metadata, installResult) } catch (e: IOException) { Log.e(TAG, "Error re-installing APK for $packageName.", e) emit(fail(installResult, packageName)) @@ -64,17 +64,19 @@ internal class ApkRestore( emit(fail(installResult, packageName)) } } + installResult.isFinished = true + emit(installResult) } - @ExperimentalCoroutinesApi - @Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO + @Suppress("ThrowsCount", "BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO @Throws(IOException::class, SecurityException::class) - private fun restore( + private suspend fun restore( + collector: FlowCollector, token: Long, packageName: String, metadata: PackageMetadata, installResult: MutableInstallResult - ) = flow { + ) { // create a cache file to write the APK into val cachedApk = File.createTempFile(packageName, ".apk", context.cacheDir) // copy APK to cache file and calculate SHA-256 hash while we are at it @@ -102,7 +104,8 @@ internal class ApkRestore( TAG, "Package $packageName expects version code ${metadata.version}," + "but has ${packageInfo.longVersionCode}." ) - // TODO should we let this one pass, maybe once we can revert PackageMetadata during backup? + // TODO should we let this one pass, + // maybe once we can revert PackageMetadata during backup? } // check signatures @@ -121,13 +124,9 @@ internal class ApkRestore( val name = pm.getApplicationLabel(appInfo) installResult.update(packageName) { result -> - result.copy( - state = IN_PROGRESS, - name = name, - icon = icon - ) + result.copy(state = IN_PROGRESS, name = name, icon = icon) } - emit(installResult) + collector.emit(installResult) // ensure system apps are actually installed and newer system apps as well if (metadata.system) { @@ -138,16 +137,15 @@ internal class ApkRestore( if (isOlder || !installedPackageInfo.isSystemApp()) throw NameNotFoundException() } catch (e: NameNotFoundException) { Log.w(TAG, "Not installing $packageName because older or not a system app here.") - emit(fail(installResult, packageName)) - return@flow + // TODO consider reporting different status here to prevent manual installs + collector.emit(fail(installResult, packageName)) + return } } // install APK and emit updates from it - apkInstaller.install(cachedApk, packageName, metadata.installer, installResult) - .collect { result -> - emit(result) - } + val result = apkInstaller.install(cachedApk, packageName, metadata.installer, installResult) + collector.emit(result) } private fun fail(installResult: MutableInstallResult, packageName: String): InstallResult { diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressAdapter.kt index 5322ab0a..ae02b8e2 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressAdapter.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressAdapter.kt @@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.restore.install 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 @@ -14,20 +15,34 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED import com.stevesoltys.seedvault.ui.AppViewHolder +import com.stevesoltys.seedvault.ui.notification.getAppName -internal class InstallProgressAdapter : Adapter() { +internal interface InstallItemListener { + fun onFailedItemClicked(item: ApkInstallResult) +} +internal class InstallProgressAdapter( + private val listener: InstallItemListener +) : Adapter() { + + private var finished = false + private val finishedComparator = FailedFirstComparator() private val items = SortedList( ApkInstallResult::class.java, object : SortedListAdapterCallback(this) { override fun areItemsTheSame(item1: ApkInstallResult, item2: ApkInstallResult) = item1.packageName == item2.packageName - override fun areContentsTheSame(oldItem: ApkInstallResult, newItem: ApkInstallResult) = - oldItem == newItem + override fun areContentsTheSame(old: ApkInstallResult, new: ApkInstallResult): Boolean { + // update failed items when finished + return if (finished) new.state != FAILED && old == new + else old == new + } - override fun compare(item1: ApkInstallResult, item2: ApkInstallResult) = - item1.compareTo(item2) + override fun compare(item1: ApkInstallResult, item2: ApkInstallResult): Int { + return if (finished) finishedComparator.compare(item1, item2) + else item1.compareTo(item2) + } }) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppInstallViewHolder { @@ -45,30 +60,46 @@ internal class InstallProgressAdapter : Adapter() { fun update(items: Collection) { this.items.replaceAll(items) } -} -internal class AppInstallViewHolder(v: View) : AppViewHolder(v) { - - fun bind(item: ApkInstallResult) { - appIcon.setImageDrawable(item.icon) - appName.text = item.name - when (item.state) { - IN_PROGRESS -> { - appStatus.visibility = INVISIBLE - progressBar.visibility = VISIBLE - } - SUCCEEDED -> { - appStatus.setImageResource(R.drawable.ic_check_green) - appStatus.visibility = VISIBLE - progressBar.visibility = INVISIBLE - } - FAILED -> { - appStatus.setImageResource(R.drawable.ic_error_red) - appStatus.visibility = VISIBLE - progressBar.visibility = INVISIBLE - } - QUEUED -> throw AssertionError() - } + fun setFinished() { + finished = true } + internal inner class AppInstallViewHolder(v: View) : AppViewHolder(v) { + + fun bind(item: ApkInstallResult) { + v.setOnClickListener(null) + v.background = null + + appIcon.setImageDrawable(item.icon) + appName.text = item.name ?: getAppName(v.context, item.packageName.toString()) + appInfo.visibility = GONE + when (item.state) { + IN_PROGRESS -> { + appStatus.visibility = INVISIBLE + progressBar.visibility = VISIBLE + } + SUCCEEDED -> { + appStatus.setImageResource(R.drawable.ic_check_green) + appStatus.visibility = VISIBLE + progressBar.visibility = INVISIBLE + } + FAILED -> { + appStatus.setImageResource(R.drawable.ic_error_red) + appStatus.visibility = VISIBLE + progressBar.visibility = INVISIBLE + if (finished) { + v.background = clickableBackground + v.setOnClickListener { + listener.onFailedItemClicked(item) + } + appInfo.visibility = VISIBLE + appInfo.setText(R.string.restore_installing_tap_to_install) + } + } + QUEUED -> throw AssertionError() + } + } + } // end AppInstallViewHolder + } diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt index 447fcb3a..2a96d476 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt @@ -1,5 +1,11 @@ package com.stevesoltys.seedvault.restore.install +import android.content.Intent +import android.content.Intent.ACTION_VIEW +import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -7,6 +13,7 @@ import android.view.ViewGroup import android.widget.Button import android.widget.ProgressBar import android.widget.TextView +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import androidx.recyclerview.widget.DividerItemDecoration @@ -17,12 +24,12 @@ import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.restore.RestoreViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel -class InstallProgressFragment : Fragment() { +class InstallProgressFragment : Fragment(), InstallItemListener { private val viewModel: RestoreViewModel by sharedViewModel() private val layoutManager = LinearLayoutManager(context) - private val adapter = InstallProgressAdapter() + private val adapter = InstallProgressAdapter(this) private lateinit var progressBar: ProgressBar private lateinit var titleView: TextView @@ -72,16 +79,59 @@ class InstallProgressFragment : Fragment() { private fun onInstallResult(installResult: InstallResult) { // skip this screen, if there are no apps to install - if (installResult.isEmpty()) viewModel.onNextClicked() + if (installResult.isEmpty) viewModel.onNextClicked() - val position = layoutManager.findFirstVisibleItemPosition() - adapter.update(installResult.getNotQueued()) - if (position == 0) layoutManager.scrollToPosition(0) + // if finished, treat all still queued apps as failed and resort/redisplay adapter items + if (installResult.isFinished) { + installResult.queuedToFailed() + adapter.setFinished() + } - installResult.getInProgress()?.let { - progressBar.progress = it.progress - progressBar.max = it.total + // update progress bar + progressBar.progress = installResult.progress + progressBar.max = installResult.total + + // just update adapter, or perform final action, if finished + if (installResult.isFinished) onFinished(installResult) + else updateAdapter(installResult.getNotQueued()) + } + + private fun onFinished(installResult: InstallResult) { + if (installResult.hasFailed) { + AlertDialog.Builder(requireContext()) + .setIcon(R.drawable.ic_warning) + .setTitle(R.string.restore_installing_error_title) + .setMessage(R.string.restore_installing_error_message) + .setPositiveButton(android.R.string.ok) { dialog, _ -> + dialog.dismiss() + } + .setOnDismissListener { + updateAdapter(installResult.getNotQueued()) + } + .show() + } else { + updateAdapter(installResult.getNotQueued()) } } + private fun updateAdapter(items: Collection) { + val position = layoutManager.findFirstVisibleItemPosition() + adapter.update(items) + if (position == 0) layoutManager.scrollToPosition(0) + } + + override fun onFailedItemClicked(item: ApkInstallResult) { + // TODO restrict intent to installer package names to one of: + // * "org.fdroid.fdroid" "org.fdroid.fdroid.privileged" + // * "com.aurora.store" + // * "com.android.vending" + val i = Intent(ACTION_VIEW, Uri.parse("market://details?id=${item.packageName}")).apply { + addFlags(FLAG_ACTIVITY_NEW_TASK) + addFlags(FLAG_ACTIVITY_CLEAR_TOP) + addFlags(FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) + // setPackage("com.aurora.store") + } + startActivity(i) + } + } diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallResult.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallResult.kt index 99d968b5..51005651 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallResult.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallResult.kt @@ -1,21 +1,38 @@ package com.stevesoltys.seedvault.restore.install import android.graphics.drawable.Drawable +import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED import java.util.concurrent.ConcurrentHashMap internal interface InstallResult { /** - * Returns true, if there is no packages to install and false otherwise. + * The number of packages already processed. */ - fun isEmpty(): Boolean + val progress: Int /** - * Returns the [ApkInstallResult] of the package currently [IN_PROGRESS] - * or null if there is no such package. + * The total number of packages to be considered for re-install. */ - fun getInProgress(): ApkInstallResult? + val total: Int + + /** + * Is true, if there is no packages to install and false otherwise. + */ + val isEmpty: Boolean + + /** + * Is true, if the installation is finished, either because all packages were processed + * or because an unexpected error happened along the way. + * Is false, if the installation is still ongoing. + */ + val isFinished: Boolean + + /** + * Is true when one or more packages failed to install. + */ + val hasFailed: Boolean /** * Get all [ApkInstallResult]s that are not in state [QUEUED]. @@ -23,32 +40,43 @@ internal interface InstallResult { fun getNotQueued(): Collection /** - * Get the [ApkInstallResult] for the given package name or null if none exists. + * Set the set of all [ApkInstallResult]s that are still [QUEUED] to [FAILED]. + * This is useful after [isFinished] is true due to an error + * and we need to treat all packages as failed that haven't been processed. */ - operator fun get(packageName: String): ApkInstallResult? + fun queuedToFailed() } -internal class MutableInstallResult(initialCapacity: Int) : InstallResult { +internal class MutableInstallResult(override val total: Int) : InstallResult { - private val installResults = ConcurrentHashMap(initialCapacity) + private val installResults = ConcurrentHashMap(total) + override val isEmpty get() = installResults.isEmpty() - override fun isEmpty() = installResults.isEmpty() - - override fun getInProgress(): ApkInstallResult? { - val filtered = installResults.filterValues { result -> result.state == IN_PROGRESS } - if (filtered.isEmpty()) return null - check(filtered.size == 1) { "More than one package in progress: ${filtered.keys}" } - return filtered.values.first() - } + @Volatile + override var isFinished = false + override val progress + get() = installResults.count { + val state = it.value.state + state != QUEUED && state != IN_PROGRESS + } + override val hasFailed get() = installResults.any { it.value.state == FAILED } override fun getNotQueued(): Collection { return installResults.filterValues { result -> result.state != QUEUED }.values } - override fun get(packageName: String) = installResults[packageName] + override fun queuedToFailed() { + installResults.forEach { entry -> + val result = entry.value + if (result.state == QUEUED) installResults[entry.key] = result.copy(state = FAILED) + } + } + + operator fun get(packageName: String) = installResults[packageName] operator fun set(packageName: String, installResult: ApkInstallResult) { installResults[packageName] = installResult + check(installResults.size <= total) { "Attempting to add more packages than total" } } fun update( @@ -63,20 +91,28 @@ internal class MutableInstallResult(initialCapacity: Int) : InstallResult { } -internal data class ApkInstallResult( +data class ApkInstallResult( val packageName: CharSequence, val progress: Int, - val total: Int, val state: ApkInstallState, val name: CharSequence? = null, - val icon: Drawable? = null + val icon: Drawable? = null, + val installerPackageName: CharSequence? = null ) : Comparable { override fun compareTo(other: ApkInstallResult): Int { return other.progress.compareTo(progress) } } -internal enum class ApkInstallState { +internal class FailedFirstComparator : Comparator { + override fun compare(a1: ApkInstallResult, a2: ApkInstallResult): Int { + return (if (a1.state == FAILED && a2.state != FAILED) -1 + else if (a2.state == FAILED && a1.state != FAILED) 1 + else a1.compareTo(a2)) + } +} + +enum class ApkInstallState { QUEUED, IN_PROGRESS, SUCCEEDED, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 20de32db..54ff96a0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -113,6 +113,9 @@ An error occurred while loading the backups. No suitable backups found at given location.\n\nThis is most likely due to a wrong recovery code or a storage error. Re-installing apps + Some apps not installed + Data can only be restored if an app is installed.\n\nTap failed apps to try to install them manually before proceeding. + Tap to install Next Restoring backup System package manager diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt index d68418dc..30af5044 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt @@ -23,9 +23,10 @@ import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectIndexed -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir @@ -80,16 +81,22 @@ internal class ApkRestoreTest : TransportTest() { apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value -> when (index) { 0 -> { - val result = value[packageName] ?: fail() + val result = value[packageName] assertEquals(QUEUED, result.state) assertEquals(1, result.progress) - assertEquals(1, result.total) + assertEquals(1, value.total) } 1 -> { - val result = value[packageName] ?: fail() + val result = value[packageName] assertEquals(FAILED, result.state) + assertTrue(value.hasFailed) + assertFalse(value.isFinished) } - else -> fail() + 2 -> { + assertTrue(value.hasFailed) + assertTrue(value.isFinished) + } + else -> fail("more values emitted") } } } @@ -106,16 +113,21 @@ internal class ApkRestoreTest : TransportTest() { apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value -> when (index) { 0 -> { - val result = value[packageName] ?: fail() + val result = value[packageName] assertEquals(QUEUED, result.state) assertEquals(1, result.progress) - assertEquals(1, result.total) + assertEquals(1, value.total) } 1 -> { - val result = value[packageName] ?: fail() + val result = value[packageName] assertEquals(FAILED, result.state) + assertTrue(value.hasFailed) } - else -> fail() + 2 -> { + assertTrue(value.hasFailed) + assertTrue(value.isFinished) + } + else -> fail("more values emitted") } } } @@ -132,34 +144,37 @@ internal class ApkRestoreTest : TransportTest() { ) } returns icon every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName - every { - apkInstaller.install( - any(), - packageName, - installerName, - any() - ) + coEvery { + apkInstaller.install(any(), packageName, installerName, any()) } throws SecurityException() apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value -> when (index) { 0 -> { - val result = value[packageName] ?: fail() + val result = value[packageName] assertEquals(QUEUED, result.state) assertEquals(1, result.progress) - assertEquals(1, result.total) + assertEquals(installerName, result.installerPackageName) + assertEquals(1, value.total) } 1 -> { - val result = value[packageName] ?: fail() + val result = value[packageName] assertEquals(IN_PROGRESS, result.state) assertEquals(appName, result.name) assertEquals(icon, result.icon) + assertFalse(value.hasFailed) } 2 -> { - val result = value[packageName] ?: fail() + val result = value[packageName] + assertTrue(value.hasFailed) assertEquals(FAILED, result.state) + assertFalse(value.isFinished) } - else -> fail() + 3 -> { + assertTrue(value.hasFailed, "1") + assertTrue(value.isFinished, "2") + } + else -> fail("more values emitted") } } } @@ -171,7 +186,6 @@ internal class ApkRestoreTest : TransportTest() { packageName, ApkInstallResult( packageName, progress = 1, - total = 1, state = SUCCEEDED ) ) @@ -187,30 +201,34 @@ internal class ApkRestoreTest : TransportTest() { ) } returns icon every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName - every { apkInstaller.install(any(), packageName, installerName, any()) } returns flowOf( - installResult - ) + coEvery { + apkInstaller.install(any(), packageName, installerName, any()) + } returns installResult var i = 0 apkRestore.restore(token, packageMetadataMap).collect { value -> when (i) { 0 -> { - val result = value[packageName] ?: fail() + val result = value[packageName] assertEquals(QUEUED, result.state) assertEquals(1, result.progress) - assertEquals(1, result.total) + assertEquals(1, value.total) } 1 -> { - val result = value[packageName] ?: fail() + val result = value[packageName] assertEquals(IN_PROGRESS, result.state) assertEquals(appName, result.name) assertEquals(icon, result.icon) } 2 -> { - val result = value[packageName] ?: fail() + val result = value[packageName] assertEquals(SUCCEEDED, result.state) } - else -> fail() + 3 -> { + assertFalse(value.hasFailed) + assertTrue(value.isFinished) + } + else -> fail("more values emitted") } i++ } @@ -246,46 +264,48 @@ internal class ApkRestoreTest : TransportTest() { val installResult = MutableInstallResult(1).apply { set( packageName, - ApkInstallResult(packageName, progress = 1, total = 1, state = SUCCEEDED) + ApkInstallResult(packageName, progress = 1, state = SUCCEEDED) ) } - every { - apkInstaller.install( - any(), - packageName, - installerName, - any() - ) - } returns flowOf(installResult) + coEvery { + apkInstaller.install(any(), packageName, installerName, any()) + } returns installResult } - var i = 0 - apkRestore.restore(token, packageMetadataMap).collect { value -> + apkRestore.restore(token, packageMetadataMap).collectIndexed { i, value -> when (i) { 0 -> { - val result = value[packageName] ?: fail() + val result = value[packageName] assertEquals(QUEUED, result.state) assertEquals(1, result.progress) - assertEquals(1, result.total) + assertEquals(1, value.total) } 1 -> { - val result = value[packageName] ?: fail() + val result = value[packageName] assertEquals(IN_PROGRESS, result.state) assertEquals(appName, result.name) assertEquals(icon, result.icon) } 2 -> { - val result = value[packageName] ?: fail() + val result = value[packageName] if (willFail) { assertEquals(FAILED, result.state) } else { assertEquals(SUCCEEDED, result.state) } + assertEquals(willFail, value.hasFailed) } - else -> fail() + 3 -> { + assertEquals(willFail, value.hasFailed) + assertTrue(value.isFinished) + } + else -> fail("more values emitted") } - i++ } } } + +private operator fun InstallResult.get(packageName: String): ApkInstallResult { + return (this as MutableInstallResult)[packageName] ?: fail("$packageName not found") +}