Re-factor and improve ApkRestore
This commit is contained in:
parent
05c39e98fa
commit
e54d96d548
10 changed files with 383 additions and 404 deletions
|
@ -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()}")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ->
|
||||
mInstallResult.update {
|
||||
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)
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,21 +23,24 @@ 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) =
|
||||
|
||||
private val diffCallback = object : DiffUtil.ItemCallback<ApkInstallResult>() {
|
||||
override fun areItemsTheSame(item1: ApkInstallResult, item2: ApkInstallResult): Boolean =
|
||||
item1.packageName == item2.packageName
|
||||
|
||||
override fun areContentsTheSame(old: ApkInstallResult, new: ApkInstallResult): Boolean {
|
||||
|
@ -44,13 +48,8 @@ internal class InstallProgressAdapter(
|
|||
return if (finished) new.state != FAILED && old == new
|
||||
else old == new
|
||||
}
|
||||
|
||||
override fun compare(item1: ApkInstallResult, item2: ApkInstallResult): Int {
|
||||
return if (finished) finishedComparator.compare(item1, item2)
|
||||
else item1.compareTo(item2)
|
||||
}
|
||||
}
|
||||
)
|
||||
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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
// if finished, treat all still queued apps as failed and resort/redisplay adapter items
|
||||
if (installResult.isFinished) {
|
||||
installResult.queuedToFailed()
|
||||
adapter.setFinished()
|
||||
}
|
||||
|
||||
} 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.getNotQueued())
|
||||
else updateAdapter(installResult.list)
|
||||
}
|
||||
}
|
||||
|
||||
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,19 +104,21 @@ 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)
|
||||
adapter.update(items) {
|
||||
if (position == 0) layoutManager.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailedItemClicked(item: ApkInstallResult) {
|
||||
try {
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
val packagesMap = mapOf(
|
||||
packageName to ApkInstallResult(
|
||||
packageName,
|
||||
progress = 1,
|
||||
state = SUCCEEDED
|
||||
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(
|
||||
val packagesMap = mapOf(
|
||||
packageName to ApkInstallResult(
|
||||
packageName,
|
||||
progress = 1,
|
||||
state = SUCCEEDED
|
||||
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)
|
||||
}
|
||||
1 -> {
|
||||
val result = value[packageName]
|
||||
assertEquals(IN_PROGRESS, result.state)
|
||||
assertEquals(appName, result.name)
|
||||
assertEquals(icon, result.icon)
|
||||
}
|
||||
2 -> {
|
||||
val result = value[packageName]
|
||||
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)
|
||||
}
|
||||
}
|
||||
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 -> {
|
||||
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, value.total)
|
||||
assertTrue(value.isFinished)
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
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 -> {
|
||||
apkRestore.installResult.test {
|
||||
awaitItem() // initial empty state
|
||||
apkRestore.restore(backup)
|
||||
awaitItem().also { queuedItem ->
|
||||
// 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")
|
||||
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 -> {
|
||||
private suspend fun TurbineTestContext<InstallResult>.awaitQueuedItem(): InstallResult {
|
||||
val item = awaitItem()
|
||||
// single package gets queued
|
||||
val result = value[packageName]
|
||||
val result = item[packageName]
|
||||
assertEquals(QUEUED, result.state)
|
||||
assertEquals(installerName, result.installerPackageName)
|
||||
assertEquals(1, result.progress)
|
||||
assertEquals(1, value.total)
|
||||
assertEquals(1, item.total)
|
||||
assertEquals(0, item.list.size) // all items still queued
|
||||
return item
|
||||
}
|
||||
1 -> {
|
||||
|
||||
private suspend fun TurbineTestContext<InstallResult>.awaitInProgressItem(): InstallResult {
|
||||
val item = awaitItem()
|
||||
// name and icon are available now
|
||||
val result = value[packageName]
|
||||
val result = item[packageName]
|
||||
assertEquals(IN_PROGRESS, result.state)
|
||||
assertEquals(appName, result.name)
|
||||
assertEquals(icon, result.icon)
|
||||
assertFalse(value.hasFailed)
|
||||
}
|
||||
else -> fail("more values emitted")
|
||||
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")
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue