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

View file

@ -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)
}
}

View file

@ -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<SelectedAppsState> =
appSelectionManager.selectedAppsLiveData
internal val installResult: LiveData<InstallResult> =
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<InstallResult> = apkRestore.installResult.asLiveData()
private val mNextButtonEnabled = MutableLiveData<Boolean>().apply { value = false }
internal val nextButtonEnabled: LiveData<Boolean> = mNextButtonEnabled
internal val installIntentCreator by lazy { InstallIntentCreator(app.packageManager) }
private val mRestoreProgress = MutableLiveData<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 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<InstallResult> {
@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)

View file

@ -49,8 +49,8 @@ internal class ApkInstaller(private val context: Context) {
cachedApks: List<File>,
packageName: String,
installerPackageName: String?,
installResult: MutableInstallResult,
) = suspendCancellableCoroutine<InstallResult> { 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<File>,
installResult: MutableInstallResult,
installResult: InstallResult,
): InstallResult {
val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!!
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_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<InstallResult>,
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) }
}
}
}

View file

@ -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<InstallProgressAdapter.AppInstallViewHolder>() {
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 {
// update failed items when finished
return if (finished) new.state != FAILED && old == new
else old == new
}
private val diffCallback = object : DiffUtil.ItemCallback<ApkInstallResult>() {
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<ApkInstallResult>) {
this.items.replaceAll(items)
fun update(items: List<ApkInstallResult>, 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) {

View file

@ -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<ApkInstallResult>) {
private fun updateAdapter(items: List<ApkInstallResult>) {
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())
}
}

View file

@ -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<String, ApkInstallResult> = 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<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.
*/
val hasFailed: Boolean
/**
* 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" }
}
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<ApkInstallResult> {
override fun compareTo(other: ApkInstallResult): Int {
return other.progress.compareTo(progress)
}
) {
val installerPackageName: CharSequence? get() = metadata.installer
}
internal class FailedFirstComparator : Comparator<ApkInstallResult> {

View file

@ -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)

View file

@ -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<Int>()) } 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<InstallResult>.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<InstallResult>.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<InstallResult>.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<InstallResult>.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<InstallResult>.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")
}