diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7568ab78..b8ea99cf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,7 +181,9 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}") testImplementation("org.junit.jupiter:junit-jupiter-params:${libs.versions.junit5.get()}") testImplementation("io.mockk:mockk:${libs.versions.mockk.get()}") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${libs.versions.coroutines.get()}") + testImplementation( + "org.jetbrains.kotlinx:kotlinx-coroutines-test:${libs.versions.coroutines.get()}" + ) testImplementation("app.cash.turbine:turbine:1.0.0") testImplementation("org.bitcoinj:bitcoinj-core:0.16.2") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}") diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt index 6b9fdd1c..60de45bc 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt @@ -108,9 +108,7 @@ internal interface LargeRestoreTestBase : LargeTestBase { withContext(Dispatchers.Main) { withTimeout(RESTORE_TIMEOUT) { - while (spyRestoreViewModel.installResult.value == null || - spyRestoreViewModel.nextButtonEnabled.value == false - ) { + while (spyRestoreViewModel.installResult.value?.isFinished != true) { delay(100) } } 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 b5ae91e9..742eaffd 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt @@ -23,7 +23,6 @@ import androidx.appcompat.content.res.AppCompatResources.getDrawable import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.asLiveData -import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope import com.stevesoltys.seedvault.BackupMonitor import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER @@ -68,10 +67,6 @@ import com.stevesoltys.seedvault.worker.IconManager import com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import org.calyxos.backup.storage.api.SnapshotItem import org.calyxos.backup.storage.api.StorageBackup @@ -117,16 +112,9 @@ internal class RestoreViewModel( internal val selectedApps: LiveData = appSelectionManager.selectedAppsLiveData - internal val installResult: LiveData = - mChosenRestorableBackup.switchMap { backup -> - // TODO does this stay stable when re-observing this LiveData? - // TODO pass in app selection done by user - getInstallResult(backup) - } - internal val installIntentCreator by lazy { InstallIntentCreator(app.packageManager) } + internal val installResult: LiveData = apkRestore.installResult.asLiveData() - private val mNextButtonEnabled = MutableLiveData().apply { value = false } - internal val nextButtonEnabled: LiveData = mNextButtonEnabled + internal val installIntentCreator by lazy { InstallIntentCreator(app.packageManager) } private val mRestoreProgress = MutableLiveData>().apply { value = LinkedList().apply { @@ -193,33 +181,26 @@ internal class RestoreViewModel( } } + suspend fun loadIcon(packageName: String, callback: (Drawable) -> Unit) { + iconManager.loadIcon(packageName, callback) + } + fun onCheckAllAppsClicked() = appSelectionManager.onCheckAllAppsClicked() fun onAppSelected(item: SelectableAppItem) = appSelectionManager.onAppSelected(item) internal fun onNextClickedAfterSelectingApps() { val backup = chosenRestorableBackup.value ?: error("No chosen backup") // replace original chosen backup with unselected packages removed - mChosenRestorableBackup.value = appSelectionManager.onAppSelectionFinished(backup) + val filteredBackup = appSelectionManager.onAppSelectionFinished(backup) + mChosenRestorableBackup.value = filteredBackup + viewModelScope.launch(ioDispatcher) { + apkRestore.restore(filteredBackup) + } // tell UI to move to InstallFragment mDisplayFragment.setEvent(RESTORE_APPS) } - private fun getInstallResult(backup: RestorableBackup): LiveData { - @Suppress("EXPERIMENTAL_API_USAGE") - return apkRestore.restore(backup) - .onStart { - Log.d(TAG, "Start InstallResult Flow") - }.catch { e -> - Log.d(TAG, "Exception in InstallResult Flow", e) - }.onCompletion { e -> - Log.d(TAG, "Completed InstallResult Flow", e) - mNextButtonEnabled.postValue(true) - } - .flowOn(ioDispatcher) - // 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) - } + fun reCheckFailedPackage(packageName: String) = apkRestore.reCheckFailedPackage(packageName) internal fun onNextClickedAfterInstallingApps() { mDisplayFragment.postEvent(RESTORE_BACKUP) 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 eb03cd75..55707d6a 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 @@ -49,8 +49,8 @@ internal class ApkInstaller(private val context: Context) { cachedApks: List, packageName: String, installerPackageName: String?, - installResult: MutableInstallResult, - ) = suspendCancellableCoroutine { cont -> + installResult: InstallResult, + ) = suspendCancellableCoroutine { cont -> val broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, i: Intent) { if (i.action != BROADCAST_ACTION) return @@ -110,7 +110,7 @@ internal class ApkInstaller(private val context: Context) { i: Intent, expectedPackageName: String, cachedApks: List, - installResult: MutableInstallResult, + installResult: InstallResult, ): InstallResult { val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!! val success = i.getIntExtra(EXTRA_STATUS, -1) == STATUS_SUCCESS 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 5bf606a5..168ff7e5 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 @@ -10,6 +10,7 @@ import android.content.pm.PackageManager import android.content.pm.PackageManager.GET_SIGNATURES import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.util.Log +import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.PackageMetadata @@ -26,10 +27,12 @@ import com.stevesoltys.seedvault.transport.backup.isSystemApp import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash import com.stevesoltys.seedvault.worker.getSignatures import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.flow.FlowCollector -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import java.io.File import java.io.IOException +import java.util.Locale private val TAG = ApkRestore::class.java.simpleName @@ -47,71 +50,66 @@ internal class ApkRestore( private val pm = context.packageManager private val storagePlugin get() = pluginManager.appPlugin - fun restore(backup: RestorableBackup) = flow { - // we don't filter out apps without APK, so the user can manually install them - val packages = backup.packageMetadataMap.filter { + private val mInstallResult = MutableStateFlow(InstallResult()) + val installResult = mInstallResult.asStateFlow() + + suspend fun restore(backup: RestorableBackup) { + val isAllowedToInstallApks = installRestriction.isAllowedToInstallApks() + // assemble all apps in a list and sort it by name, than transform it back to a (sorted) map + val packages = backup.packageMetadataMap.mapNotNull { (packageName, metadata) -> // We need to exclude the DocumentsProvider used to retrieve backup data. // Otherwise, it gets killed when we install it, terminating our restoration. - it.key != storagePlugin.providerPackageName - } - val isAllowedToInstallApks = installRestriction.isAllowedToInstallApks() - val total = packages.size - var progress = 0 - - // queue all packages and emit LiveData - val installResult = MutableInstallResult(total) - packages.forEach { (packageName, metadata) -> - progress++ - installResult[packageName] = ApkInstallResult( + if (packageName == storagePlugin.providerPackageName) return@mapNotNull null + // The @pm@ package needs to be included in [backup], but can't be installed like an app + if (packageName == MAGIC_PACKAGE_MANAGER) return@mapNotNull null + // we don't filter out apps without APK, so the user can manually install them + ApkInstallResult( packageName = packageName, - progress = progress, state = if (isAllowedToInstallApks) QUEUED else FAILED, - name = metadata.name?.toString(), - installerPackageName = metadata.installer + metadata = metadata, ) + }.sortedBy { apkInstallResult -> // sort list alphabetically ignoring case + apkInstallResult.name?.lowercase(Locale.getDefault()) + }.associateBy { apkInstallResult -> // use a map, so we can quickly update individual apps + apkInstallResult.packageName } - if (isAllowedToInstallApks) { - emit(installResult) - } else { - installResult.isFinished = true - emit(installResult) - return@flow + if (!isAllowedToInstallApks) { // not allowed to install, so return list with all failed + mInstallResult.value = InstallResult(packages, true) + return } + mInstallResult.value = InstallResult(packages) - // re-install individual packages and emit updates - for ((packageName, metadata) in packages) { + // re-install individual packages and emit updates (start from last and work your way up) + for ((packageName, apkInstallResult) in packages.asIterable().reversed()) { try { - if (metadata.hasApk()) { - restore(this, backup, packageName, metadata, installResult) + if (apkInstallResult.metadata.hasApk()) { + restore(backup, packageName, apkInstallResult.metadata) } else { - emit(installResult.fail(packageName)) + mInstallResult.update { it.fail(packageName) } } } catch (e: IOException) { Log.e(TAG, "Error re-installing APK for $packageName.", e) - emit(installResult.fail(packageName)) + mInstallResult.update { it.fail(packageName) } } catch (e: SecurityException) { Log.e(TAG, "Security error re-installing APK for $packageName.", e) - emit(installResult.fail(packageName)) + mInstallResult.update { it.fail(packageName) } } catch (e: TimeoutCancellationException) { Log.e(TAG, "Timeout while re-installing APK for $packageName.", e) - emit(installResult.fail(packageName)) + mInstallResult.update { it.fail(packageName) } } catch (e: Exception) { Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e) - emit(installResult.fail(packageName)) + mInstallResult.update { it.fail(packageName) } } } - installResult.isFinished = true - emit(installResult) + mInstallResult.update { it.copy(isFinished = true) } } @Suppress("ThrowsCount") @Throws(IOException::class, SecurityException::class) private suspend fun restore( - collector: FlowCollector, backup: RestorableBackup, packageName: String, metadata: PackageMetadata, - installResult: MutableInstallResult, ) { // cache the APK and get its hash val (cachedApk, sha256) = cacheApk(backup.version, backup.token, backup.salt, packageName) @@ -156,32 +154,30 @@ internal class ApkRestore( val icon = appInfo?.loadIcon(pm) val name = appInfo?.let { pm.getApplicationLabel(it).toString() } - installResult.update(packageName) { result -> - result.copy(state = IN_PROGRESS, name = name, icon = icon) - } - collector.emit(installResult) - - // ensure system apps are actually already installed and newer system apps as well - if (metadata.system) { - shouldInstallSystemApp(packageName, metadata, installResult)?.let { - collector.emit(it) - return + mInstallResult.update { + it.update(packageName) { result -> + result.copy(state = IN_PROGRESS, name = name, icon = icon) } } + // ensure system apps are actually already installed and newer system apps as well + if (metadata.system) shouldInstallSystemApp(packageName, metadata)?.let { + mInstallResult.value = it + return + } + // process further APK splits, if available - val cachedApks = - cacheSplitsIfNeeded(backup, packageName, cachedApk, metadata.splits) + val cachedApks = cacheSplitsIfNeeded(backup, packageName, cachedApk, metadata.splits) if (cachedApks == null) { Log.w(TAG, "Not installing $packageName because of incompatible splits.") - collector.emit(installResult.fail(packageName)) + mInstallResult.update { it.fail(packageName) } return } // install APK and emit updates from it val result = - apkInstaller.install(cachedApks, packageName, metadata.installer, installResult) - collector.emit(result) + apkInstaller.install(cachedApks, packageName, metadata.installer, installResult.value) + mInstallResult.value = result } /** @@ -240,7 +236,6 @@ internal class ApkRestore( val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir) // copy APK to cache file and calculate SHA-256 hash while we are at it val inputStream = if (version == 0.toByte()) { - @Suppress("Deprecation") legacyStoragePlugin.getApkInputStream(token, packageName, suffix) } else { val name = crypto.getNameForApk(salt, packageName, suffix) @@ -257,26 +252,38 @@ internal class ApkRestore( private fun shouldInstallSystemApp( packageName: String, metadata: PackageMetadata, - installResult: MutableInstallResult, ): InstallResult? { val installedPackageInfo = try { pm.getPackageInfo(packageName, 0) } catch (e: PackageManager.NameNotFoundException) { Log.w(TAG, "Not installing system app $packageName because not installed here.") // we report a different FAILED status here to prevent manual installs - return installResult.fail(packageName, FAILED_SYSTEM_APP) + return installResult.value.fail(packageName, FAILED_SYSTEM_APP) } // metadata.version is not null, because here hasApk() must be true val isOlder = metadata.version!! <= installedPackageInfo.longVersionCode return if (isOlder) { Log.w(TAG, "Not installing $packageName because ours is older.") - installResult.update(packageName) { it.copy(state = SUCCEEDED) } + installResult.value.update(packageName) { it.copy(state = SUCCEEDED) } } else if (!installedPackageInfo.isSystemApp()) { Log.w(TAG, "Not installing $packageName because not a system app here.") - installResult.update(packageName) { it.copy(state = SUCCEEDED) } + installResult.value.update(packageName) { it.copy(state = SUCCEEDED) } } else { null // everything is good, we can re-install this } } + /** + * Once [InstallResult.isFinished] is true, + * this can be called to re-check a package in state [FAILED]. + * If it is now installed, the state will be changed to [SUCCEEDED]. + */ + fun reCheckFailedPackage(packageName: String) { + check(installResult.value.isFinished) { + "re-checking failed packages only allowed when finished" + } + if (context.packageManager.isInstalled(packageName)) mInstallResult.update { result -> + result.update(packageName) { it.copy(state = SUCCEEDED) } + } + } } 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 a4413f41..e92a4f7b 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 @@ -5,15 +5,16 @@ package com.stevesoltys.seedvault.restore.install +import android.graphics.drawable.Drawable import android.view.LayoutInflater import android.view.View import android.view.View.GONE import android.view.View.INVISIBLE import android.view.View.VISIBLE import android.view.ViewGroup +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView.Adapter -import androidx.recyclerview.widget.SortedList -import androidx.recyclerview.widget.SortedListAdapterCallback import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP @@ -22,35 +23,33 @@ 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 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch internal interface InstallItemListener { fun onFailedItemClicked(item: ApkInstallResult) } internal class InstallProgressAdapter( + private val scope: CoroutineScope, + private val iconLoader: suspend (ApkInstallResult, (Drawable) -> Unit) -> Unit, 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(old: ApkInstallResult, new: ApkInstallResult): Boolean { - // update failed items when finished - return if (finished) new.state != FAILED && old == new - else old == new - } + private val diffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(item1: ApkInstallResult, item2: ApkInstallResult): Boolean = + item1.packageName == item2.packageName - override fun compare(item1: ApkInstallResult, item2: ApkInstallResult): Int { - return if (finished) finishedComparator.compare(item1, item2) - else item1.compareTo(item2) - } + override fun areContentsTheSame(old: ApkInstallResult, new: ApkInstallResult): Boolean { + // update failed items when finished + return if (finished) new.state != FAILED && old == new + else old == new } - ) + } + private val differ = AsyncListDiffer(this, diffCallback) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppInstallViewHolder { val v = LayoutInflater.from(parent.context) @@ -58,27 +57,33 @@ internal class InstallProgressAdapter( return AppInstallViewHolder(v) } - override fun getItemCount() = items.size() + override fun getItemCount() = differ.currentList.size override fun onBindViewHolder(holder: AppInstallViewHolder, position: Int) { - holder.bind(items[position]) + holder.bind(differ.currentList[position]) } - fun update(items: Collection) { - this.items.replaceAll(items) + fun update(items: List, block: Runnable) { + differ.submitList(items, block) } fun setFinished() { finished = true } - internal inner class AppInstallViewHolder(v: View) : AppViewHolder(v) { + override fun onViewRecycled(holder: AppInstallViewHolder) { + holder.iconJob?.cancel() + } + internal inner class AppInstallViewHolder(v: View) : AppViewHolder(v) { + var iconJob: Job? = null fun bind(item: ApkInstallResult) { v.setOnClickListener(null) v.background = null - appIcon.setImageDrawable(item.icon) + if (item.icon == null) iconJob = scope.launch { + iconLoader(item, appIcon::setImageDrawable) + } else appIcon.setImageDrawable(item.icon) appName.text = item.name ?: getAppName(v.context, item.packageName.toString()) appInfo.visibility = GONE when (item.state) { 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 28e64e40..926dab69 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 @@ -8,6 +8,7 @@ package com.stevesoltys.seedvault.restore.install import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent +import android.graphics.drawable.Drawable import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -20,6 +21,7 @@ import android.widget.Toast.LENGTH_LONG import androidx.activity.result.contract.ActivityResultContract import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.stevesoltys.seedvault.R @@ -31,7 +33,8 @@ class InstallProgressFragment : Fragment(), InstallItemListener { private val viewModel: RestoreViewModel by sharedViewModel() private val layoutManager = LinearLayoutManager(context) - private val adapter = InstallProgressAdapter(this) + private val adapter = InstallProgressAdapter(lifecycleScope, this::loadIcon, this) + private var hasShownFailDialog = false private lateinit var progressBar: ProgressBar private lateinit var titleView: TextView @@ -72,35 +75,27 @@ class InstallProgressFragment : Fragment(), InstallItemListener { viewModel.installResult.observe(viewLifecycleOwner) { result -> onInstallResult(result) } - - viewModel.nextButtonEnabled.observe(viewLifecycleOwner) { enabled -> - button.isEnabled = enabled - } } private fun onInstallResult(installResult: InstallResult) { // skip this screen, if there are no apps to install - if (installResult.isFinished && installResult.isEmpty) { + if (installResult.hasNoAppsToInstall) { viewModel.onNextClickedAfterInstallingApps() + } else { + // 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.list) } - - // if finished, treat all still queued apps as failed and resort/redisplay adapter items - if (installResult.isFinished) { - installResult.queuedToFailed() - adapter.setFinished() - } - - // 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) { + adapter.setFinished() + button.isEnabled = true + if (!hasShownFailDialog && installResult.hasFailed) { AlertDialog.Builder(requireContext()) .setIcon(R.drawable.ic_warning) .setTitle(R.string.restore_installing_error_title) @@ -109,18 +104,20 @@ class InstallProgressFragment : Fragment(), InstallItemListener { dialog.dismiss() } .setOnDismissListener { - updateAdapter(installResult.getNotQueued()) + hasShownFailDialog = true + updateAdapter(installResult.list) } .show() } else { - updateAdapter(installResult.getNotQueued()) + updateAdapter(installResult.list) } } - private fun updateAdapter(items: Collection) { + private fun updateAdapter(items: List) { val position = layoutManager.findFirstVisibleItemPosition() - adapter.update(items) - if (position == 0) layoutManager.scrollToPosition(0) + adapter.update(items) { + if (position == 0) layoutManager.scrollToPosition(0) + } } override fun onFailedItemClicked(item: ApkInstallResult) { @@ -131,14 +128,14 @@ class InstallProgressFragment : Fragment(), InstallItemListener { } } + private suspend fun loadIcon(item: ApkInstallResult, callback: (Drawable) -> Unit) { + viewModel.loadIcon(item.packageName, callback) + } + private val installAppLauncher = registerForActivityResult(InstallApp()) { packageName -> val result = viewModel.installResult.value ?: return@registerForActivityResult if (result.isFinished) { - val changed = result.reCheckFailedPackage( - requireContext().packageManager, - packageName.toString() - ) - if (changed) adapter.update(result.getNotQueued()) + viewModel.reCheckFailedPackage(packageName.toString()) } } 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 0da7b37c..39d92d9e 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 @@ -5,129 +5,79 @@ package com.stevesoltys.seedvault.restore.install -import android.content.pm.PackageManager import android.graphics.drawable.Drawable +import androidx.annotation.VisibleForTesting +import com.stevesoltys.seedvault.metadata.PackageMetadata 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 com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED -import java.util.concurrent.ConcurrentHashMap - -internal interface InstallResult { - /** - * The number of packages already processed. - */ - val progress: Int - - /** - * The total number of packages to be considered for re-install. - */ - val total: Int - - /** - * Is true, if there is no packages to install and false otherwise. - */ - val isEmpty: Boolean +internal data class InstallResult( + @get:VisibleForTesting + val installResults: Map = mapOf(), /** * 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 + val isFinished: Boolean = false, +) { + /** + * The number of packages already processed. + */ + val progress: Int = installResults.count { + val state = it.value.state + state != QUEUED && state != IN_PROGRESS + } + + /** + * The total number of packages to be considered for re-install. + */ + val total: Int = installResults.size + + /** + * A list of all [ApkInstallResult]s that are not in state [QUEUED]. + */ + val list: List = installResults.filterValues { result -> + result.state != QUEUED + }.values.run { + if (isFinished) sortedWith(FailedFirstComparator()) else this + }.toList() + + /** + * Is true, if there is no packages to install and false otherwise. + */ + val hasNoAppsToInstall: Boolean = installResults.isEmpty() && isFinished /** * Is true when one or more packages failed to install. */ - val hasFailed: Boolean - - /** - * Get all [ApkInstallResult]s that are not in state [QUEUED]. - */ - fun getNotQueued(): Collection - - /** - * 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. - */ - fun queuedToFailed() - - /** - * Once [isFinished] is true, this can be called to re-check a package in state [FAILED]. - * If it is now installed, the state will be changed to [SUCCEEDED] and true returned. - */ - fun reCheckFailedPackage(pm: PackageManager, packageName: String): Boolean -} - -internal class MutableInstallResult(override val total: Int) : InstallResult { - - private val installResults = ConcurrentHashMap(total) - override val isEmpty get() = installResults.isEmpty() - - @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 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" } - } + val hasFailed: Boolean = installResults.any { it.value.state == FAILED } fun update( packageName: String, updateFun: (ApkInstallResult) -> ApkInstallResult, - ): MutableInstallResult { - val result = get(packageName) + ): InstallResult { + val results = installResults.toMutableMap() + val result = results[packageName] check(result != null) { "ApkRestoreResult for $packageName does not exist." } - installResults[packageName] = updateFun(result) - return this + results[packageName] = updateFun(result) + return copy(installResults = results) } fun fail(packageName: String, state: ApkInstallState = FAILED): InstallResult { return update(packageName) { it.copy(state = state) } } - - override fun reCheckFailedPackage(pm: PackageManager, packageName: String): Boolean { - check(isFinished) { "re-checking failed packages only allowed when finished" } - if (pm.isInstalled(packageName)) { - update(packageName) { it.copy(state = SUCCEEDED) } - return true - } - return false - } - } data class ApkInstallResult( val packageName: String, - val progress: Int, val state: ApkInstallState, - val name: String? = null, + val metadata: PackageMetadata, + val name: String? = metadata.name?.toString(), val icon: Drawable? = null, - val installerPackageName: CharSequence? = null, -) : Comparable { - override fun compareTo(other: ApkInstallResult): Int { - return other.progress.compareTo(progress) - } +) { + val installerPackageName: CharSequence? get() = metadata.installer } internal class FailedFirstComparator : Comparator { diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt index 6b33c969..0fb2f2b9 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt @@ -10,6 +10,7 @@ import android.content.pm.PackageManager import android.content.pm.Signature import android.graphics.drawable.Drawable import android.util.PackageUtils +import app.cash.turbine.test import com.stevesoltys.seedvault.assertReadEquals import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.metadata.ApkSplit @@ -19,6 +20,9 @@ import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.restore.RestorableBackup +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.transport.TransportTest import com.stevesoltys.seedvault.worker.ApkBackup import io.mockk.coEvery @@ -27,12 +31,12 @@ import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.slot import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.collectIndexed import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertArrayEquals 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 import java.io.ByteArrayInputStream @@ -52,6 +56,7 @@ internal class ApkBackupRestoreTest : TransportTest() { } private val storagePluginManager: StoragePluginManager = mockk() + @Suppress("Deprecation") private val legacyStoragePlugin: LegacyStoragePlugin = mockk() private val storagePlugin: StoragePlugin<*> = mockk() @@ -151,23 +156,50 @@ internal class ApkBackupRestoreTest : TransportTest() { } returns true every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName coEvery { storagePlugin.getInputStream(token, suffixName) } returns splitInputStream + val resultMap = mapOf( + packageName to ApkInstallResult( + packageName, + state = SUCCEEDED, + metadata = packageMetadataMap[packageName] ?: fail(), + ) + ) coEvery { apkInstaller.install(capture(cacheFiles), packageName, installerName, any()) - } returns MutableInstallResult(1).apply { - set( - packageName, ApkInstallResult( - packageName, - progress = 1, - state = ApkInstallState.SUCCEEDED - ) - ) - } + } returns InstallResult(resultMap) val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap)) - apkRestore.restore(backup).collectIndexed { i, value -> - assertFalse(value.hasFailed) - assertEquals(1, value.total) - if (i == 3) assertTrue(value.isFinished) + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + awaitItem().also { + assertFalse(it.hasFailed) + assertEquals(1, it.total) + assertEquals(0, it.list.size) + assertEquals(QUEUED, it.installResults[packageName]?.state) + assertFalse(it.isFinished) + } + awaitItem().also { + assertFalse(it.hasFailed) + assertEquals(1, it.total) + assertEquals(1, it.list.size) + assertEquals(IN_PROGRESS, it.installResults[packageName]?.state) + assertFalse(it.isFinished) + } + awaitItem().also { + assertFalse(it.hasFailed) + assertEquals(1, it.total) + assertEquals(1, it.list.size) + assertEquals(SUCCEEDED, it.installResults[packageName]?.state) + assertFalse(it.isFinished) + } + awaitItem().also { + assertFalse(it.hasFailed) + assertEquals(1, it.total) + assertEquals(1, it.list.size) + assertEquals(SUCCEEDED, it.installResults[packageName]?.state) + assertTrue(it.isFinished) + } + ensureAllEventsConsumed() } val apkFile = File(apkPath.captured) 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 c58dbbe2..dd31e429 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 @@ -12,6 +12,8 @@ import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.graphics.drawable.Drawable +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test import com.stevesoltys.seedvault.getRandomBase64 import com.stevesoltys.seedvault.getRandomByteArray import com.stevesoltys.seedvault.getRandomString @@ -32,13 +34,11 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.collectIndexed import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions 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 import java.io.ByteArrayInputStream @@ -109,8 +109,10 @@ internal class ApkRestoreTest : TransportTest() { coEvery { storagePlugin.getInputStream(token, name) } returns apkInputStream every { storagePlugin.providerPackageName } returns storageProviderPackageName - apkRestore.restore(backup).collectIndexed { i, value -> - assertQueuedFailFinished(i, value) + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedFailFinished() } } @@ -126,8 +128,10 @@ internal class ApkRestoreTest : TransportTest() { every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo every { storagePlugin.providerPackageName } returns storageProviderPackageName - apkRestore.restore(backup).collectIndexed { i, value -> - assertQueuedFailFinished(i, value) + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedFailFinished() } } @@ -140,22 +144,23 @@ internal class ApkRestoreTest : TransportTest() { } throws SecurityException() every { storagePlugin.providerPackageName } returns storageProviderPackageName - apkRestore.restore(backup).collectIndexed { i, value -> - assertQueuedProgressFailFinished(i, value) + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressFailFinished() } } @Test fun `test successful run`(@TempDir tmpDir: Path) = runBlocking { - val installResult = MutableInstallResult(1).apply { - set( - packageName, ApkInstallResult( - packageName, - progress = 1, - state = SUCCEEDED - ) + val packagesMap = mapOf( + packageName to ApkInstallResult( + packageName, + state = SUCCEEDED, + metadata = PackageMetadata(), ) - } + ) + val installResult = InstallResult(packagesMap) every { installRestriction.isAllowedToInstallApks() } returns true cacheBaseApkAndGetInfo(tmpDir) @@ -164,8 +169,10 @@ internal class ApkRestoreTest : TransportTest() { } returns installResult every { storagePlugin.providerPackageName } returns storageProviderPackageName - apkRestore.restore(backup).collectIndexed { i, value -> - assertQueuedProgressSuccessFinished(i, value) + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressSuccessFinished() } } @@ -174,19 +181,17 @@ internal class ApkRestoreTest : TransportTest() { // This is a legacy backup with version 0 val backup = backup.copy(backupMetadata = backup.backupMetadata.copy(version = 0)) // Install will be successful - val installResult = MutableInstallResult(1).apply { - set( - packageName, ApkInstallResult( - packageName, - progress = 1, - state = SUCCEEDED - ) + val packagesMap = mapOf( + packageName to ApkInstallResult( + packageName, + state = SUCCEEDED, + metadata = PackageMetadata(), ) - } + ) + val installResult = InstallResult(packagesMap) every { installRestriction.isAllowedToInstallApks() } returns true every { strictContext.cacheDir } returns File(tmpDir.toString()) - @Suppress("Deprecation") coEvery { legacyStoragePlugin.getApkInputStream(token, packageName, "") } returns apkInputStream @@ -198,8 +203,10 @@ internal class ApkRestoreTest : TransportTest() { } returns installResult every { storagePlugin.providerPackageName } returns storageProviderPackageName - apkRestore.restore(backup).collectIndexed { i, value -> - assertQueuedProgressSuccessFinished(i, value) + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressSuccessFinished() } } @@ -228,12 +235,14 @@ internal class ApkRestoreTest : TransportTest() { every { pm.getPackageInfo(packageName, 0) } returns installedPackageInfo every { installedPackageInfo.longVersionCode } returns packageMetadata.version!! - 1 if (isSystemApp) { // if the installed app is not a system app, we don't install - val installResult = MutableInstallResult(1).apply { - set( + val packagesMap = mapOf( + packageName to ApkInstallResult( packageName, - ApkInstallResult(packageName, progress = 1, state = SUCCEEDED) + state = SUCCEEDED, + metadata = PackageMetadata(), ) - } + ) + val installResult = InstallResult(packagesMap) coEvery { apkInstaller.install( match { it.size == 1 }, @@ -245,33 +254,23 @@ internal class ApkRestoreTest : TransportTest() { } } - apkRestore.restore(backup).collectIndexed { i, value -> - when (i) { - 0 -> { - val result = value[packageName] - assertEquals(QUEUED, result.state) - assertEquals(1, result.progress) - assertEquals(1, value.total) + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + awaitQueuedItem() + awaitInProgressItem() + awaitItem().also { systemItem -> + val result = systemItem[packageName] + if (willFail) { + assertEquals(FAILED_SYSTEM_APP, result.state) + } else { + assertEquals(SUCCEEDED, result.state) } - 1 -> { - val result = value[packageName] - assertEquals(IN_PROGRESS, result.state) - assertEquals(appName, result.name) - assertEquals(icon, result.icon) - } - 2 -> { - val result = value[packageName] - if (willFail) { - assertEquals(FAILED_SYSTEM_APP, result.state) - } else { - assertEquals(SUCCEEDED, result.state) - } - } - 3 -> { - assertTrue(value.isFinished) - } - else -> fail("more values emitted") } + awaitItem().also { finishedItem -> + assertTrue(finishedItem.isFinished) + } + ensureAllEventsConsumed() } } @@ -297,8 +296,10 @@ internal class ApkRestoreTest : TransportTest() { } returns false every { storagePlugin.providerPackageName } returns storageProviderPackageName - apkRestore.restore(backup).collectIndexed { i, value -> - assertQueuedProgressFailFinished(i, value) + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressFailFinished() } } @@ -321,8 +322,10 @@ internal class ApkRestoreTest : TransportTest() { } returns ByteArrayInputStream(getRandomByteArray()) every { storagePlugin.providerPackageName } returns storageProviderPackageName - apkRestore.restore(backup).collectIndexed { i, value -> - assertQueuedProgressFailFinished(i, value) + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressFailFinished() } } @@ -345,8 +348,10 @@ internal class ApkRestoreTest : TransportTest() { coEvery { storagePlugin.getInputStream(token, suffixName) } throws IOException() every { storagePlugin.providerPackageName } returns storageProviderPackageName - apkRestore.restore(backup).collectIndexed { i, value -> - assertQueuedProgressFailFinished(i, value) + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressFailFinished() } } @@ -385,60 +390,58 @@ internal class ApkRestoreTest : TransportTest() { coEvery { storagePlugin.getInputStream(token, suffixName2) } returns split2InputStream every { storagePlugin.providerPackageName } returns storageProviderPackageName + val resultMap = mapOf( + packageName to ApkInstallResult( + packageName, + state = SUCCEEDED, + metadata = PackageMetadata(), + ) + ) coEvery { apkInstaller.install(match { it.size == 3 }, packageName, installerName, any()) - } returns MutableInstallResult(1).apply { - set( - packageName, ApkInstallResult( - packageName, - progress = 1, - state = SUCCEEDED - ) - ) - } + } returns InstallResult(resultMap) - apkRestore.restore(backup).collectIndexed { i, value -> - assertQueuedProgressSuccessFinished(i, value) + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressSuccessFinished() } } @Test - fun `storage provider app does not get reinstalled`(@TempDir tmpDir: Path) = runBlocking { + fun `storage provider app does not get reinstalled`() = runBlocking { every { installRestriction.isAllowedToInstallApks() } returns true // set the storage provider package name to match our current package name, // and ensure that the current package is therefore skipped. every { storagePlugin.providerPackageName } returns packageName - apkRestore.restore(backup).collectIndexed { i, value -> - when (i) { - 0 -> { - assertFalse(value.isFinished) - } - 1 -> { - // the only package provided should have been filtered, leaving 0 packages. - assertEquals(0, value.total) - assertTrue(value.isFinished) - } - else -> fail("more values emitted") + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + awaitItem().also { finishedItem -> + // the only package provided should have been filtered, leaving 0 packages. + assertEquals(0, finishedItem.total) + assertTrue(finishedItem.isFinished) } + ensureAllEventsConsumed() } } @Test - fun `no apks get installed when blocked by policy`(@TempDir tmpDir: Path) = runBlocking { + fun `no apks get installed when blocked by policy`() = runBlocking { every { installRestriction.isAllowedToInstallApks() } returns false every { storagePlugin.providerPackageName } returns storageProviderPackageName - apkRestore.restore(backup).collectIndexed { i, value -> - when (i) { - 0 -> { - // single package fails without attempting to install it - assertEquals(1, value.total) - assertEquals(FAILED, value[packageName].state) - assertTrue(value.isFinished) - } - else -> fail("more values emitted") + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + awaitItem().also { queuedItem -> + // single package fails without attempting to install it + assertEquals(1, queuedItem.total) + assertEquals(FAILED, queuedItem[packageName].state) + assertTrue(queuedItem.isFinished) } + ensureAllEventsConsumed() } } @@ -456,74 +459,78 @@ internal class ApkRestoreTest : TransportTest() { every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName } - private fun assertQueuedFailFinished(step: Int, value: InstallResult) = when (step) { - 0 -> assertQueuedProgress(step, value) - 1 -> { - val result = value[packageName] + private suspend fun TurbineTestContext.assertQueuedFailFinished() { + awaitQueuedItem() + awaitItem().also { failedItem -> + val result = failedItem[packageName] assertEquals(FAILED, result.state) - assertTrue(value.hasFailed) - assertFalse(value.isFinished) + assertTrue(failedItem.hasFailed) + assertFalse(failedItem.isFinished) } - 2 -> { - assertTrue(value.hasFailed) - assertTrue(value.isFinished) + awaitItem().also { finishedItem -> + assertTrue(finishedItem.hasFailed) + assertTrue(finishedItem.isFinished) } - else -> fail("more values emitted") + ensureAllEventsConsumed() } - private fun assertQueuedProgressSuccessFinished(step: Int, value: InstallResult) = when (step) { - 0 -> assertQueuedProgress(step, value) - 1 -> assertQueuedProgress(step, value) - 2 -> { - val result = value[packageName] + private suspend fun TurbineTestContext.assertQueuedProgressSuccessFinished() { + awaitQueuedItem() + awaitInProgressItem() + awaitItem().also { successItem -> + val result = successItem[packageName] assertEquals(SUCCEEDED, result.state) } - 3 -> { - assertFalse(value.hasFailed) - assertTrue(value.isFinished) + awaitItem().also { finishedItem -> + assertFalse(finishedItem.hasFailed) + assertTrue(finishedItem.isFinished) } - else -> fail("more values emitted") + ensureAllEventsConsumed() } - private fun assertQueuedProgressFailFinished(step: Int, value: InstallResult) = when (step) { - 0 -> assertQueuedProgress(step, value) - 1 -> assertQueuedProgress(step, value) - 2 -> { + private suspend fun TurbineTestContext.assertQueuedProgressFailFinished() { + awaitQueuedItem() + awaitInProgressItem() + awaitItem().also { failedItem -> // app install has failed - val result = value[packageName] + val result = failedItem[packageName] assertEquals(FAILED, result.state) - assertTrue(value.hasFailed) - assertFalse(value.isFinished) + assertTrue(failedItem.hasFailed) + assertFalse(failedItem.isFinished) } - 3 -> { - assertTrue(value.hasFailed) - assertTrue(value.isFinished) + awaitItem().also { finishedItem -> + assertTrue(finishedItem.hasFailed) + assertTrue(finishedItem.isFinished) } - else -> fail("more values emitted") + ensureAllEventsConsumed() } - private fun assertQueuedProgress(step: Int, value: InstallResult) = when (step) { - 0 -> { - // single package gets queued - val result = value[packageName] - assertEquals(QUEUED, result.state) - assertEquals(installerName, result.installerPackageName) - assertEquals(1, result.progress) - assertEquals(1, value.total) - } - 1 -> { - // name and icon are available now - val result = value[packageName] - assertEquals(IN_PROGRESS, result.state) - assertEquals(appName, result.name) - assertEquals(icon, result.icon) - assertFalse(value.hasFailed) - } - else -> fail("more values emitted") + private suspend fun TurbineTestContext.awaitQueuedItem(): InstallResult { + val item = awaitItem() + // single package gets queued + val result = item[packageName] + assertEquals(QUEUED, result.state) + assertEquals(installerName, result.installerPackageName) + assertEquals(1, item.total) + assertEquals(0, item.list.size) // all items still queued + return item + } + + private suspend fun TurbineTestContext.awaitInProgressItem(): InstallResult { + val item = awaitItem() + // name and icon are available now + val result = item[packageName] + assertEquals(IN_PROGRESS, result.state) + assertEquals(appName, result.name) + assertEquals(icon, result.icon) + assertFalse(item.hasFailed) + assertEquals(1, item.total) + assertEquals(1, item.list.size) + return item } } private operator fun InstallResult.get(packageName: String): ApkInstallResult { - return (this as MutableInstallResult)[packageName] ?: Assertions.fail("$packageName not found") + return this.installResults[packageName] ?: Assertions.fail("$packageName not found") }