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") +}