Re-factor and improve ApkRestore

This commit is contained in:
Torsten Grote 2024-05-28 14:25:47 -03:00
parent 05c39e98fa
commit e54d96d548
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
10 changed files with 383 additions and 404 deletions

View file

@ -181,7 +181,9 @@ dependencies {
testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}") testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}")
testImplementation("org.junit.jupiter:junit-jupiter-params:${libs.versions.junit5.get()}") testImplementation("org.junit.jupiter:junit-jupiter-params:${libs.versions.junit5.get()}")
testImplementation("io.mockk:mockk:${libs.versions.mockk.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("app.cash.turbine:turbine:1.0.0")
testImplementation("org.bitcoinj:bitcoinj-core:0.16.2") testImplementation("org.bitcoinj:bitcoinj-core:0.16.2")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}")

View file

@ -108,9 +108,7 @@ internal interface LargeRestoreTestBase : LargeTestBase {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
withTimeout(RESTORE_TIMEOUT) { withTimeout(RESTORE_TIMEOUT) {
while (spyRestoreViewModel.installResult.value == null || while (spyRestoreViewModel.installResult.value?.isFinished != true) {
spyRestoreViewModel.nextButtonEnabled.value == false
) {
delay(100) delay(100)
} }
} }

View file

@ -23,7 +23,6 @@ import androidx.appcompat.content.res.AppCompatResources.getDrawable
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.stevesoltys.seedvault.BackupMonitor import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER 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 com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers 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 kotlinx.coroutines.launch
import org.calyxos.backup.storage.api.SnapshotItem import org.calyxos.backup.storage.api.SnapshotItem
import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.api.StorageBackup
@ -117,16 +112,9 @@ internal class RestoreViewModel(
internal val selectedApps: LiveData<SelectedAppsState> = internal val selectedApps: LiveData<SelectedAppsState> =
appSelectionManager.selectedAppsLiveData appSelectionManager.selectedAppsLiveData
internal val installResult: LiveData<InstallResult> = internal val installResult: LiveData<InstallResult> = apkRestore.installResult.asLiveData()
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) }
private val mNextButtonEnabled = MutableLiveData<Boolean>().apply { value = false } internal val installIntentCreator by lazy { InstallIntentCreator(app.packageManager) }
internal val nextButtonEnabled: LiveData<Boolean> = mNextButtonEnabled
private val mRestoreProgress = MutableLiveData<LinkedList<AppRestoreResult>>().apply { private val mRestoreProgress = MutableLiveData<LinkedList<AppRestoreResult>>().apply {
value = LinkedList<AppRestoreResult>().apply { value = LinkedList<AppRestoreResult>().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 onCheckAllAppsClicked() = appSelectionManager.onCheckAllAppsClicked()
fun onAppSelected(item: SelectableAppItem) = appSelectionManager.onAppSelected(item) fun onAppSelected(item: SelectableAppItem) = appSelectionManager.onAppSelected(item)
internal fun onNextClickedAfterSelectingApps() { internal fun onNextClickedAfterSelectingApps() {
val backup = chosenRestorableBackup.value ?: error("No chosen backup") val backup = chosenRestorableBackup.value ?: error("No chosen backup")
// replace original chosen backup with unselected packages removed // 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 // tell UI to move to InstallFragment
mDisplayFragment.setEvent(RESTORE_APPS) mDisplayFragment.setEvent(RESTORE_APPS)
} }
private fun getInstallResult(backup: RestorableBackup): LiveData<InstallResult> { fun reCheckFailedPackage(packageName: String) = apkRestore.reCheckFailedPackage(packageName)
@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)
}
internal fun onNextClickedAfterInstallingApps() { internal fun onNextClickedAfterInstallingApps() {
mDisplayFragment.postEvent(RESTORE_BACKUP) mDisplayFragment.postEvent(RESTORE_BACKUP)

View file

@ -49,8 +49,8 @@ internal class ApkInstaller(private val context: Context) {
cachedApks: List<File>, cachedApks: List<File>,
packageName: String, packageName: String,
installerPackageName: String?, installerPackageName: String?,
installResult: MutableInstallResult, installResult: InstallResult,
) = suspendCancellableCoroutine<InstallResult> { cont -> ) = suspendCancellableCoroutine { cont ->
val broadcastReceiver = object : BroadcastReceiver() { val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, i: Intent) { override fun onReceive(context: Context, i: Intent) {
if (i.action != BROADCAST_ACTION) return if (i.action != BROADCAST_ACTION) return
@ -110,7 +110,7 @@ internal class ApkInstaller(private val context: Context) {
i: Intent, i: Intent,
expectedPackageName: String, expectedPackageName: String,
cachedApks: List<File>, cachedApks: List<File>,
installResult: MutableInstallResult, installResult: InstallResult,
): InstallResult { ): InstallResult {
val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!! val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!!
val success = i.getIntExtra(EXTRA_STATUS, -1) == STATUS_SUCCESS val success = i.getIntExtra(EXTRA_STATUS, -1) == STATUS_SUCCESS

View file

@ -10,6 +10,7 @@ import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNATURES import android.content.pm.PackageManager.GET_SIGNATURES
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.PackageMetadata 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.copyStreamsAndGetHash
import com.stevesoltys.seedvault.worker.getSignatures import com.stevesoltys.seedvault.worker.getSignatures
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.Locale
private val TAG = ApkRestore::class.java.simpleName private val TAG = ApkRestore::class.java.simpleName
@ -47,71 +50,66 @@ internal class ApkRestore(
private val pm = context.packageManager private val pm = context.packageManager
private val storagePlugin get() = pluginManager.appPlugin private val storagePlugin get() = pluginManager.appPlugin
fun restore(backup: RestorableBackup) = flow { private val mInstallResult = MutableStateFlow(InstallResult())
// we don't filter out apps without APK, so the user can manually install them val installResult = mInstallResult.asStateFlow()
val packages = backup.packageMetadataMap.filter {
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. // We need to exclude the DocumentsProvider used to retrieve backup data.
// Otherwise, it gets killed when we install it, terminating our restoration. // Otherwise, it gets killed when we install it, terminating our restoration.
it.key != storagePlugin.providerPackageName if (packageName == storagePlugin.providerPackageName) return@mapNotNull null
} // The @pm@ package needs to be included in [backup], but can't be installed like an app
val isAllowedToInstallApks = installRestriction.isAllowedToInstallApks() if (packageName == MAGIC_PACKAGE_MANAGER) return@mapNotNull null
val total = packages.size // we don't filter out apps without APK, so the user can manually install them
var progress = 0 ApkInstallResult(
// queue all packages and emit LiveData
val installResult = MutableInstallResult(total)
packages.forEach { (packageName, metadata) ->
progress++
installResult[packageName] = ApkInstallResult(
packageName = packageName, packageName = packageName,
progress = progress,
state = if (isAllowedToInstallApks) QUEUED else FAILED, state = if (isAllowedToInstallApks) QUEUED else FAILED,
name = metadata.name?.toString(), metadata = metadata,
installerPackageName = metadata.installer
) )
}.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) { if (!isAllowedToInstallApks) { // not allowed to install, so return list with all failed
emit(installResult) mInstallResult.value = InstallResult(packages, true)
} else { return
installResult.isFinished = true
emit(installResult)
return@flow
} }
mInstallResult.value = InstallResult(packages)
// re-install individual packages and emit updates // re-install individual packages and emit updates (start from last and work your way up)
for ((packageName, metadata) in packages) { for ((packageName, apkInstallResult) in packages.asIterable().reversed()) {
try { try {
if (metadata.hasApk()) { if (apkInstallResult.metadata.hasApk()) {
restore(this, backup, packageName, metadata, installResult) restore(backup, packageName, apkInstallResult.metadata)
} else { } else {
emit(installResult.fail(packageName)) mInstallResult.update { it.fail(packageName) }
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error re-installing APK for $packageName.", e) Log.e(TAG, "Error re-installing APK for $packageName.", e)
emit(installResult.fail(packageName)) mInstallResult.update { it.fail(packageName) }
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.e(TAG, "Security error re-installing APK for $packageName.", e) Log.e(TAG, "Security error re-installing APK for $packageName.", e)
emit(installResult.fail(packageName)) mInstallResult.update { it.fail(packageName) }
} catch (e: TimeoutCancellationException) { } catch (e: TimeoutCancellationException) {
Log.e(TAG, "Timeout while re-installing APK for $packageName.", e) Log.e(TAG, "Timeout while re-installing APK for $packageName.", e)
emit(installResult.fail(packageName)) mInstallResult.update { it.fail(packageName) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e) Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e)
emit(installResult.fail(packageName)) mInstallResult.update { it.fail(packageName) }
} }
} }
installResult.isFinished = true mInstallResult.update { it.copy(isFinished = true) }
emit(installResult)
} }
@Suppress("ThrowsCount") @Suppress("ThrowsCount")
@Throws(IOException::class, SecurityException::class) @Throws(IOException::class, SecurityException::class)
private suspend fun restore( private suspend fun restore(
collector: FlowCollector<InstallResult>,
backup: RestorableBackup, backup: RestorableBackup,
packageName: String, packageName: String,
metadata: PackageMetadata, metadata: PackageMetadata,
installResult: MutableInstallResult,
) { ) {
// cache the APK and get its hash // cache the APK and get its hash
val (cachedApk, sha256) = cacheApk(backup.version, backup.token, backup.salt, packageName) val (cachedApk, sha256) = cacheApk(backup.version, backup.token, backup.salt, packageName)
@ -156,32 +154,30 @@ internal class ApkRestore(
val icon = appInfo?.loadIcon(pm) val icon = appInfo?.loadIcon(pm)
val name = appInfo?.let { pm.getApplicationLabel(it).toString() } val name = appInfo?.let { pm.getApplicationLabel(it).toString() }
installResult.update(packageName) { result -> mInstallResult.update {
result.copy(state = IN_PROGRESS, name = name, icon = icon) it.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
} }
} }
// 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 // process further APK splits, if available
val cachedApks = val cachedApks = cacheSplitsIfNeeded(backup, packageName, cachedApk, metadata.splits)
cacheSplitsIfNeeded(backup, packageName, cachedApk, metadata.splits)
if (cachedApks == null) { if (cachedApks == null) {
Log.w(TAG, "Not installing $packageName because of incompatible splits.") Log.w(TAG, "Not installing $packageName because of incompatible splits.")
collector.emit(installResult.fail(packageName)) mInstallResult.update { it.fail(packageName) }
return return
} }
// install APK and emit updates from it // install APK and emit updates from it
val result = val result =
apkInstaller.install(cachedApks, packageName, metadata.installer, installResult) apkInstaller.install(cachedApks, packageName, metadata.installer, installResult.value)
collector.emit(result) mInstallResult.value = result
} }
/** /**
@ -240,7 +236,6 @@ internal class ApkRestore(
val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir) val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir)
// copy APK to cache file and calculate SHA-256 hash while we are at it // copy APK to cache file and calculate SHA-256 hash while we are at it
val inputStream = if (version == 0.toByte()) { val inputStream = if (version == 0.toByte()) {
@Suppress("Deprecation")
legacyStoragePlugin.getApkInputStream(token, packageName, suffix) legacyStoragePlugin.getApkInputStream(token, packageName, suffix)
} else { } else {
val name = crypto.getNameForApk(salt, packageName, suffix) val name = crypto.getNameForApk(salt, packageName, suffix)
@ -257,26 +252,38 @@ internal class ApkRestore(
private fun shouldInstallSystemApp( private fun shouldInstallSystemApp(
packageName: String, packageName: String,
metadata: PackageMetadata, metadata: PackageMetadata,
installResult: MutableInstallResult,
): InstallResult? { ): InstallResult? {
val installedPackageInfo = try { val installedPackageInfo = try {
pm.getPackageInfo(packageName, 0) pm.getPackageInfo(packageName, 0)
} catch (e: PackageManager.NameNotFoundException) { } catch (e: PackageManager.NameNotFoundException) {
Log.w(TAG, "Not installing system app $packageName because not installed here.") Log.w(TAG, "Not installing system app $packageName because not installed here.")
// we report a different FAILED status here to prevent manual installs // 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 // metadata.version is not null, because here hasApk() must be true
val isOlder = metadata.version!! <= installedPackageInfo.longVersionCode val isOlder = metadata.version!! <= installedPackageInfo.longVersionCode
return if (isOlder) { return if (isOlder) {
Log.w(TAG, "Not installing $packageName because ours is older.") 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()) { } else if (!installedPackageInfo.isSystemApp()) {
Log.w(TAG, "Not installing $packageName because not a system app here.") 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 { } else {
null // everything is good, we can re-install this 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) }
}
}
} }

View file

@ -5,15 +5,16 @@
package com.stevesoltys.seedvault.restore.install package com.stevesoltys.seedvault.restore.install
import android.graphics.drawable.Drawable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.View.GONE import android.view.View.GONE
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView.Adapter 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.R
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP 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.restore.install.ApkInstallState.SUCCEEDED
import com.stevesoltys.seedvault.ui.AppViewHolder import com.stevesoltys.seedvault.ui.AppViewHolder
import com.stevesoltys.seedvault.ui.notification.getAppName import com.stevesoltys.seedvault.ui.notification.getAppName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
internal interface InstallItemListener { internal interface InstallItemListener {
fun onFailedItemClicked(item: ApkInstallResult) fun onFailedItemClicked(item: ApkInstallResult)
} }
internal class InstallProgressAdapter( internal class InstallProgressAdapter(
private val scope: CoroutineScope,
private val iconLoader: suspend (ApkInstallResult, (Drawable) -> Unit) -> Unit,
private val listener: InstallItemListener, private val listener: InstallItemListener,
) : Adapter<InstallProgressAdapter.AppInstallViewHolder>() { ) : Adapter<InstallProgressAdapter.AppInstallViewHolder>() {
private var finished = false private var finished = false
private val finishedComparator = FailedFirstComparator()
private val items = SortedList(
ApkInstallResult::class.java,
object : SortedListAdapterCallback<ApkInstallResult>(this) {
override fun areItemsTheSame(item1: ApkInstallResult, item2: ApkInstallResult) =
item1.packageName == item2.packageName
override fun areContentsTheSame(old: ApkInstallResult, new: ApkInstallResult): Boolean { private val diffCallback = object : DiffUtil.ItemCallback<ApkInstallResult>() {
// update failed items when finished override fun areItemsTheSame(item1: ApkInstallResult, item2: ApkInstallResult): Boolean =
return if (finished) new.state != FAILED && old == new item1.packageName == item2.packageName
else old == new
}
override fun compare(item1: ApkInstallResult, item2: ApkInstallResult): Int { override fun areContentsTheSame(old: ApkInstallResult, new: ApkInstallResult): Boolean {
return if (finished) finishedComparator.compare(item1, item2) // update failed items when finished
else item1.compareTo(item2) 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 { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppInstallViewHolder {
val v = LayoutInflater.from(parent.context) val v = LayoutInflater.from(parent.context)
@ -58,27 +57,33 @@ internal class InstallProgressAdapter(
return AppInstallViewHolder(v) return AppInstallViewHolder(v)
} }
override fun getItemCount() = items.size() override fun getItemCount() = differ.currentList.size
override fun onBindViewHolder(holder: AppInstallViewHolder, position: Int) { override fun onBindViewHolder(holder: AppInstallViewHolder, position: Int) {
holder.bind(items[position]) holder.bind(differ.currentList[position])
} }
fun update(items: Collection<ApkInstallResult>) { fun update(items: List<ApkInstallResult>, block: Runnable) {
this.items.replaceAll(items) differ.submitList(items, block)
} }
fun setFinished() { fun setFinished() {
finished = true 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) { fun bind(item: ApkInstallResult) {
v.setOnClickListener(null) v.setOnClickListener(null)
v.background = 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()) appName.text = item.name ?: getAppName(v.context, item.packageName.toString())
appInfo.visibility = GONE appInfo.visibility = GONE
when (item.state) { when (item.state) {

View file

@ -8,6 +8,7 @@ package com.stevesoltys.seedvault.restore.install
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.drawable.Drawable
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -20,6 +21,7 @@ import android.widget.Toast.LENGTH_LONG
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
@ -31,7 +33,8 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
private val viewModel: RestoreViewModel by sharedViewModel() private val viewModel: RestoreViewModel by sharedViewModel()
private val layoutManager = LinearLayoutManager(context) private val layoutManager = LinearLayoutManager(context)
private val adapter = InstallProgressAdapter(this) private val adapter = InstallProgressAdapter(lifecycleScope, this::loadIcon, this)
private var hasShownFailDialog = false
private lateinit var progressBar: ProgressBar private lateinit var progressBar: ProgressBar
private lateinit var titleView: TextView private lateinit var titleView: TextView
@ -72,35 +75,27 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
viewModel.installResult.observe(viewLifecycleOwner) { result -> viewModel.installResult.observe(viewLifecycleOwner) { result ->
onInstallResult(result) onInstallResult(result)
} }
viewModel.nextButtonEnabled.observe(viewLifecycleOwner) { enabled ->
button.isEnabled = enabled
}
} }
private fun onInstallResult(installResult: InstallResult) { private fun onInstallResult(installResult: InstallResult) {
// skip this screen, if there are no apps to install // skip this screen, if there are no apps to install
if (installResult.isFinished && installResult.isEmpty) { if (installResult.hasNoAppsToInstall) {
viewModel.onNextClickedAfterInstallingApps() 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) { private fun onFinished(installResult: InstallResult) {
if (installResult.hasFailed) { adapter.setFinished()
button.isEnabled = true
if (!hasShownFailDialog && installResult.hasFailed) {
AlertDialog.Builder(requireContext()) AlertDialog.Builder(requireContext())
.setIcon(R.drawable.ic_warning) .setIcon(R.drawable.ic_warning)
.setTitle(R.string.restore_installing_error_title) .setTitle(R.string.restore_installing_error_title)
@ -109,18 +104,20 @@ class InstallProgressFragment : Fragment(), InstallItemListener {
dialog.dismiss() dialog.dismiss()
} }
.setOnDismissListener { .setOnDismissListener {
updateAdapter(installResult.getNotQueued()) hasShownFailDialog = true
updateAdapter(installResult.list)
} }
.show() .show()
} else { } else {
updateAdapter(installResult.getNotQueued()) updateAdapter(installResult.list)
} }
} }
private fun updateAdapter(items: Collection<ApkInstallResult>) { private fun updateAdapter(items: List<ApkInstallResult>) {
val position = layoutManager.findFirstVisibleItemPosition() val position = layoutManager.findFirstVisibleItemPosition()
adapter.update(items) adapter.update(items) {
if (position == 0) layoutManager.scrollToPosition(0) if (position == 0) layoutManager.scrollToPosition(0)
}
} }
override fun onFailedItemClicked(item: ApkInstallResult) { 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 -> private val installAppLauncher = registerForActivityResult(InstallApp()) { packageName ->
val result = viewModel.installResult.value ?: return@registerForActivityResult val result = viewModel.installResult.value ?: return@registerForActivityResult
if (result.isFinished) { if (result.isFinished) {
val changed = result.reCheckFailedPackage( viewModel.reCheckFailedPackage(packageName.toString())
requireContext().packageManager,
packageName.toString()
)
if (changed) adapter.update(result.getNotQueued())
} }
} }

View file

@ -5,129 +5,79 @@
package com.stevesoltys.seedvault.restore.install package com.stevesoltys.seedvault.restore.install
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable 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.FAILED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED 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<String, ApkInstallResult> = mapOf(),
/** /**
* Is true, if the installation is finished, either because all packages were processed * Is true, if the installation is finished, either because all packages were processed
* or because an unexpected error happened along the way. * or because an unexpected error happened along the way.
* Is false, if the installation is still ongoing. * 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<ApkInstallResult> = 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. * Is true when one or more packages failed to install.
*/ */
val hasFailed: Boolean val hasFailed: Boolean = installResults.any { it.value.state == FAILED }
/**
* Get all [ApkInstallResult]s that are not in state [QUEUED].
*/
fun getNotQueued(): Collection<ApkInstallResult>
/**
* 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<String, ApkInstallResult>(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<ApkInstallResult> {
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" }
}
fun update( fun update(
packageName: String, packageName: String,
updateFun: (ApkInstallResult) -> ApkInstallResult, updateFun: (ApkInstallResult) -> ApkInstallResult,
): MutableInstallResult { ): InstallResult {
val result = get(packageName) val results = installResults.toMutableMap()
val result = results[packageName]
check(result != null) { "ApkRestoreResult for $packageName does not exist." } check(result != null) { "ApkRestoreResult for $packageName does not exist." }
installResults[packageName] = updateFun(result) results[packageName] = updateFun(result)
return this return copy(installResults = results)
} }
fun fail(packageName: String, state: ApkInstallState = FAILED): InstallResult { fun fail(packageName: String, state: ApkInstallState = FAILED): InstallResult {
return update(packageName) { it.copy(state = state) } 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( data class ApkInstallResult(
val packageName: String, val packageName: String,
val progress: Int,
val state: ApkInstallState, val state: ApkInstallState,
val name: String? = null, val metadata: PackageMetadata,
val name: String? = metadata.name?.toString(),
val icon: Drawable? = null, val icon: Drawable? = null,
val installerPackageName: CharSequence? = null, ) {
) : Comparable<ApkInstallResult> { val installerPackageName: CharSequence? get() = metadata.installer
override fun compareTo(other: ApkInstallResult): Int {
return other.progress.compareTo(progress)
}
} }
internal class FailedFirstComparator : Comparator<ApkInstallResult> { internal class FailedFirstComparator : Comparator<ApkInstallResult> {

View file

@ -10,6 +10,7 @@ import android.content.pm.PackageManager
import android.content.pm.Signature import android.content.pm.Signature
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.PackageUtils import android.util.PackageUtils
import app.cash.turbine.test
import com.stevesoltys.seedvault.assertReadEquals import com.stevesoltys.seedvault.assertReadEquals
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.ApkSplit 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.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.restore.RestorableBackup 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.transport.TransportTest
import com.stevesoltys.seedvault.worker.ApkBackup import com.stevesoltys.seedvault.worker.ApkBackup
import io.mockk.coEvery import io.mockk.coEvery
@ -27,12 +31,12 @@ import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.slot import io.mockk.slot
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue 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.Test
import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
@ -52,6 +56,7 @@ internal class ApkBackupRestoreTest : TransportTest() {
} }
private val storagePluginManager: StoragePluginManager = mockk() private val storagePluginManager: StoragePluginManager = mockk()
@Suppress("Deprecation") @Suppress("Deprecation")
private val legacyStoragePlugin: LegacyStoragePlugin = mockk() private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
private val storagePlugin: StoragePlugin<*> = mockk() private val storagePlugin: StoragePlugin<*> = mockk()
@ -151,23 +156,50 @@ internal class ApkBackupRestoreTest : TransportTest() {
} returns true } returns true
every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName
coEvery { storagePlugin.getInputStream(token, suffixName) } returns splitInputStream coEvery { storagePlugin.getInputStream(token, suffixName) } returns splitInputStream
val resultMap = mapOf(
packageName to ApkInstallResult(
packageName,
state = SUCCEEDED,
metadata = packageMetadataMap[packageName] ?: fail(),
)
)
coEvery { coEvery {
apkInstaller.install(capture(cacheFiles), packageName, installerName, any()) apkInstaller.install(capture(cacheFiles), packageName, installerName, any())
} returns MutableInstallResult(1).apply { } returns InstallResult(resultMap)
set(
packageName, ApkInstallResult(
packageName,
progress = 1,
state = ApkInstallState.SUCCEEDED
)
)
}
val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap)) val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap))
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertFalse(value.hasFailed) awaitItem() // initial empty state
assertEquals(1, value.total) apkRestore.restore(backup)
if (i == 3) assertTrue(value.isFinished) 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) val apkFile = File(apkPath.captured)

View file

@ -12,6 +12,8 @@ import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.stevesoltys.seedvault.getRandomBase64 import com.stevesoltys.seedvault.getRandomBase64
import com.stevesoltys.seedvault.getRandomByteArray import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
@ -32,13 +34,11 @@ import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue 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.Test
import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
@ -109,8 +109,10 @@ internal class ApkRestoreTest : TransportTest() {
coEvery { storagePlugin.getInputStream(token, name) } returns apkInputStream coEvery { storagePlugin.getInputStream(token, name) } returns apkInputStream
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedFailFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedFailFinished()
} }
} }
@ -126,8 +128,10 @@ internal class ApkRestoreTest : TransportTest() {
every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedFailFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedFailFinished()
} }
} }
@ -140,22 +144,23 @@ internal class ApkRestoreTest : TransportTest() {
} throws SecurityException() } throws SecurityException()
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedProgressFailFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedProgressFailFinished()
} }
} }
@Test @Test
fun `test successful run`(@TempDir tmpDir: Path) = runBlocking { fun `test successful run`(@TempDir tmpDir: Path) = runBlocking {
val installResult = MutableInstallResult(1).apply { val packagesMap = mapOf(
set( packageName to ApkInstallResult(
packageName, ApkInstallResult( packageName,
packageName, state = SUCCEEDED,
progress = 1, metadata = PackageMetadata(),
state = SUCCEEDED
)
) )
} )
val installResult = InstallResult(packagesMap)
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
cacheBaseApkAndGetInfo(tmpDir) cacheBaseApkAndGetInfo(tmpDir)
@ -164,8 +169,10 @@ internal class ApkRestoreTest : TransportTest() {
} returns installResult } returns installResult
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedProgressSuccessFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedProgressSuccessFinished()
} }
} }
@ -174,19 +181,17 @@ internal class ApkRestoreTest : TransportTest() {
// This is a legacy backup with version 0 // This is a legacy backup with version 0
val backup = backup.copy(backupMetadata = backup.backupMetadata.copy(version = 0)) val backup = backup.copy(backupMetadata = backup.backupMetadata.copy(version = 0))
// Install will be successful // Install will be successful
val installResult = MutableInstallResult(1).apply { val packagesMap = mapOf(
set( packageName to ApkInstallResult(
packageName, ApkInstallResult( packageName,
packageName, state = SUCCEEDED,
progress = 1, metadata = PackageMetadata(),
state = SUCCEEDED
)
) )
} )
val installResult = InstallResult(packagesMap)
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
every { strictContext.cacheDir } returns File(tmpDir.toString()) every { strictContext.cacheDir } returns File(tmpDir.toString())
@Suppress("Deprecation")
coEvery { coEvery {
legacyStoragePlugin.getApkInputStream(token, packageName, "") legacyStoragePlugin.getApkInputStream(token, packageName, "")
} returns apkInputStream } returns apkInputStream
@ -198,8 +203,10 @@ internal class ApkRestoreTest : TransportTest() {
} returns installResult } returns installResult
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedProgressSuccessFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedProgressSuccessFinished()
} }
} }
@ -228,12 +235,14 @@ internal class ApkRestoreTest : TransportTest() {
every { pm.getPackageInfo(packageName, 0) } returns installedPackageInfo every { pm.getPackageInfo(packageName, 0) } returns installedPackageInfo
every { installedPackageInfo.longVersionCode } returns packageMetadata.version!! - 1 every { installedPackageInfo.longVersionCode } returns packageMetadata.version!! - 1
if (isSystemApp) { // if the installed app is not a system app, we don't install if (isSystemApp) { // if the installed app is not a system app, we don't install
val installResult = MutableInstallResult(1).apply { val packagesMap = mapOf(
set( packageName to ApkInstallResult(
packageName, packageName,
ApkInstallResult(packageName, progress = 1, state = SUCCEEDED) state = SUCCEEDED,
metadata = PackageMetadata(),
) )
} )
val installResult = InstallResult(packagesMap)
coEvery { coEvery {
apkInstaller.install( apkInstaller.install(
match { it.size == 1 }, match { it.size == 1 },
@ -245,33 +254,23 @@ internal class ApkRestoreTest : TransportTest() {
} }
} }
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
when (i) { awaitItem() // initial empty state
0 -> { apkRestore.restore(backup)
val result = value[packageName] awaitQueuedItem()
assertEquals(QUEUED, result.state) awaitInProgressItem()
assertEquals(1, result.progress) awaitItem().also { systemItem ->
assertEquals(1, value.total) 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 } returns false
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedProgressFailFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedProgressFailFinished()
} }
} }
@ -321,8 +322,10 @@ internal class ApkRestoreTest : TransportTest() {
} returns ByteArrayInputStream(getRandomByteArray()) } returns ByteArrayInputStream(getRandomByteArray())
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedProgressFailFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedProgressFailFinished()
} }
} }
@ -345,8 +348,10 @@ internal class ApkRestoreTest : TransportTest() {
coEvery { storagePlugin.getInputStream(token, suffixName) } throws IOException() coEvery { storagePlugin.getInputStream(token, suffixName) } throws IOException()
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedProgressFailFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedProgressFailFinished()
} }
} }
@ -385,60 +390,58 @@ internal class ApkRestoreTest : TransportTest() {
coEvery { storagePlugin.getInputStream(token, suffixName2) } returns split2InputStream coEvery { storagePlugin.getInputStream(token, suffixName2) } returns split2InputStream
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
val resultMap = mapOf(
packageName to ApkInstallResult(
packageName,
state = SUCCEEDED,
metadata = PackageMetadata(),
)
)
coEvery { coEvery {
apkInstaller.install(match { it.size == 3 }, packageName, installerName, any()) apkInstaller.install(match { it.size == 3 }, packageName, installerName, any())
} returns MutableInstallResult(1).apply { } returns InstallResult(resultMap)
set(
packageName, ApkInstallResult(
packageName,
progress = 1,
state = SUCCEEDED
)
)
}
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
assertQueuedProgressSuccessFinished(i, value) awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedProgressSuccessFinished()
} }
} }
@Test @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 every { installRestriction.isAllowedToInstallApks() } returns true
// set the storage provider package name to match our current package name, // set the storage provider package name to match our current package name,
// and ensure that the current package is therefore skipped. // and ensure that the current package is therefore skipped.
every { storagePlugin.providerPackageName } returns packageName every { storagePlugin.providerPackageName } returns packageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
when (i) { awaitItem() // initial empty state
0 -> { apkRestore.restore(backup)
assertFalse(value.isFinished) awaitItem().also { finishedItem ->
} // the only package provided should have been filtered, leaving 0 packages.
1 -> { assertEquals(0, finishedItem.total)
// the only package provided should have been filtered, leaving 0 packages. assertTrue(finishedItem.isFinished)
assertEquals(0, value.total)
assertTrue(value.isFinished)
}
else -> fail("more values emitted")
} }
ensureAllEventsConsumed()
} }
} }
@Test @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 { installRestriction.isAllowedToInstallApks() } returns false
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkRestore.restore(backup).collectIndexed { i, value -> apkRestore.installResult.test {
when (i) { awaitItem() // initial empty state
0 -> { apkRestore.restore(backup)
// single package fails without attempting to install it awaitItem().also { queuedItem ->
assertEquals(1, value.total) // single package fails without attempting to install it
assertEquals(FAILED, value[packageName].state) assertEquals(1, queuedItem.total)
assertTrue(value.isFinished) assertEquals(FAILED, queuedItem[packageName].state)
} assertTrue(queuedItem.isFinished)
else -> fail("more values emitted")
} }
ensureAllEventsConsumed()
} }
} }
@ -456,74 +459,78 @@ internal class ApkRestoreTest : TransportTest() {
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
} }
private fun assertQueuedFailFinished(step: Int, value: InstallResult) = when (step) { private suspend fun TurbineTestContext<InstallResult>.assertQueuedFailFinished() {
0 -> assertQueuedProgress(step, value) awaitQueuedItem()
1 -> { awaitItem().also { failedItem ->
val result = value[packageName] val result = failedItem[packageName]
assertEquals(FAILED, result.state) assertEquals(FAILED, result.state)
assertTrue(value.hasFailed) assertTrue(failedItem.hasFailed)
assertFalse(value.isFinished) assertFalse(failedItem.isFinished)
} }
2 -> { awaitItem().also { finishedItem ->
assertTrue(value.hasFailed) assertTrue(finishedItem.hasFailed)
assertTrue(value.isFinished) assertTrue(finishedItem.isFinished)
} }
else -> fail("more values emitted") ensureAllEventsConsumed()
} }
private fun assertQueuedProgressSuccessFinished(step: Int, value: InstallResult) = when (step) { private suspend fun TurbineTestContext<InstallResult>.assertQueuedProgressSuccessFinished() {
0 -> assertQueuedProgress(step, value) awaitQueuedItem()
1 -> assertQueuedProgress(step, value) awaitInProgressItem()
2 -> { awaitItem().also { successItem ->
val result = value[packageName] val result = successItem[packageName]
assertEquals(SUCCEEDED, result.state) assertEquals(SUCCEEDED, result.state)
} }
3 -> { awaitItem().also { finishedItem ->
assertFalse(value.hasFailed) assertFalse(finishedItem.hasFailed)
assertTrue(value.isFinished) assertTrue(finishedItem.isFinished)
} }
else -> fail("more values emitted") ensureAllEventsConsumed()
} }
private fun assertQueuedProgressFailFinished(step: Int, value: InstallResult) = when (step) { private suspend fun TurbineTestContext<InstallResult>.assertQueuedProgressFailFinished() {
0 -> assertQueuedProgress(step, value) awaitQueuedItem()
1 -> assertQueuedProgress(step, value) awaitInProgressItem()
2 -> { awaitItem().also { failedItem ->
// app install has failed // app install has failed
val result = value[packageName] val result = failedItem[packageName]
assertEquals(FAILED, result.state) assertEquals(FAILED, result.state)
assertTrue(value.hasFailed) assertTrue(failedItem.hasFailed)
assertFalse(value.isFinished) assertFalse(failedItem.isFinished)
} }
3 -> { awaitItem().also { finishedItem ->
assertTrue(value.hasFailed) assertTrue(finishedItem.hasFailed)
assertTrue(value.isFinished) assertTrue(finishedItem.isFinished)
} }
else -> fail("more values emitted") ensureAllEventsConsumed()
} }
private fun assertQueuedProgress(step: Int, value: InstallResult) = when (step) { private suspend fun TurbineTestContext<InstallResult>.awaitQueuedItem(): InstallResult {
0 -> { val item = awaitItem()
// single package gets queued // single package gets queued
val result = value[packageName] val result = item[packageName]
assertEquals(QUEUED, result.state) assertEquals(QUEUED, result.state)
assertEquals(installerName, result.installerPackageName) assertEquals(installerName, result.installerPackageName)
assertEquals(1, result.progress) assertEquals(1, item.total)
assertEquals(1, value.total) assertEquals(0, item.list.size) // all items still queued
} return item
1 -> { }
// name and icon are available now
val result = value[packageName] private suspend fun TurbineTestContext<InstallResult>.awaitInProgressItem(): InstallResult {
assertEquals(IN_PROGRESS, result.state) val item = awaitItem()
assertEquals(appName, result.name) // name and icon are available now
assertEquals(icon, result.icon) val result = item[packageName]
assertFalse(value.hasFailed) assertEquals(IN_PROGRESS, result.state)
} assertEquals(appName, result.name)
else -> fail("more values emitted") 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 { 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")
} }