Allow the user to manually re-install apps before data restore starts

When one or more apps fail to install, the user is shown a dialog explaining that we need the apps installed in order for restore to work.
After the dialog is dismissed, the list of apps is resorted so failed apps are at the top. They are made clickable and the user is brought to an app store to re-install them.
This commit is contained in:
Torsten Grote 2020-10-08 17:19:20 -03:00 committed by Chirayu Desai
parent 747384fb59
commit d6cb34c211
8 changed files with 293 additions and 157 deletions

View file

@ -15,7 +15,6 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations.switchMap
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.stevesoltys.seedvault.ui.AppBackupState
import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R
@ -27,6 +26,14 @@ import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
import com.stevesoltys.seedvault.restore.install.ApkRestore
import com.stevesoltys.seedvault.restore.install.InstallResult
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import com.stevesoltys.seedvault.ui.AppBackupState
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_ALLOWED
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_INSTALLED
@ -35,20 +42,12 @@ import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_QUOTA_EXCEEDED
import com.stevesoltys.seedvault.ui.AppBackupState.IN_PROGRESS
import com.stevesoltys.seedvault.ui.AppBackupState.NOT_YET_BACKED_UP
import com.stevesoltys.seedvault.ui.AppBackupState.SUCCEEDED
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
import com.stevesoltys.seedvault.restore.install.InstallResult
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
import com.stevesoltys.seedvault.restore.install.ApkRestore
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import com.stevesoltys.seedvault.ui.notification.getAppName
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onCompletion
@ -87,7 +86,6 @@ internal class RestoreViewModel(
internal val installResult: LiveData<InstallResult> =
switchMap(mChosenRestorableBackup) { backup ->
@Suppress("EXPERIMENTAL_API_USAGE")
getInstallResult(backup)
}
@ -151,8 +149,8 @@ internal class RestoreViewModel(
closeSession()
}
@ExperimentalCoroutinesApi
private fun getInstallResult(restorableBackup: RestorableBackup): LiveData<InstallResult> {
@Suppress("EXPERIMENTAL_API_USAGE")
return apkRestore.restore(restorableBackup.token, restorableBackup.packageMetadataMap)
.onStart {
Log.d(TAG, "Start InstallResult Flow")
@ -163,7 +161,9 @@ internal class RestoreViewModel(
mNextButtonEnabled.postValue(true)
}
.flowOn(ioDispatcher)
.asLiveData()
// collect on the same thread, so concurrency issues don't mess up live data updates
// e.g. InstallResult#isFinished isn't reported too early.
.asLiveData(ioDispatcher)
}
internal fun onNextClicked() {
@ -336,7 +336,8 @@ internal class RestoreViewModel(
/**
* The restore operation has begun.
*
* @param numPackages The total number of packages being processed in this restore operation.
* @param numPackages The total number of packages
* being processed in this restore operation.
*/
override fun restoreStarting(numPackages: Int) {
// noop

View file

@ -20,13 +20,12 @@ import android.util.Log
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import java.io.IOException
import kotlin.coroutines.resume
private val TAG: String = ApkInstaller::class.java.simpleName
@ -37,26 +36,24 @@ internal class ApkInstaller(private val context: Context) {
private val pm: PackageManager = context.packageManager
private val installer: PackageInstaller = pm.packageInstaller
@ExperimentalCoroutinesApi
@Throws(IOException::class, SecurityException::class)
internal fun install(
internal suspend fun install(
cachedApk: File,
packageName: String,
installerPackageName: String?,
installResult: MutableInstallResult
) = callbackFlow {
) = suspendCancellableCoroutine<InstallResult> { cont ->
val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, i: Intent) {
if (i.action != BROADCAST_ACTION) return
offer(onBroadcastReceived(i, packageName, cachedApk, installResult))
close()
context.unregisterReceiver(this)
cont.resume(onBroadcastReceived(i, packageName, cachedApk, installResult))
}
}
context.registerReceiver(broadcastReceiver, IntentFilter(BROADCAST_ACTION))
cont.invokeOnCancellation { context.unregisterReceiver(broadcastReceiver) }
install(cachedApk, installerPackageName)
awaitClose { context.unregisterReceiver(broadcastReceiver) }
}
private fun install(cachedApk: File, installerPackageName: String?) {
@ -65,7 +62,6 @@ internal class ApkInstaller(private val context: Context) {
}
// Don't set more sessionParams intentionally here.
// We saw strange permission issues when doing setInstallReason() or setting installFlags.
@Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
val session = installer.openSession(installer.createSession(sessionParams))
val sizeBytes = cachedApk.length()
session.use { s ->
@ -110,6 +106,7 @@ internal class ApkInstaller(private val context: Context) {
}
// update status and offer result
// TODO maybe don't back up statusMsg=INSTALL_FAILED_TEST_ONLY apps in the first place?
val status = if (success) SUCCEEDED else FAILED
return installResult.update(packageName) { it.copy(state = status) }
}

View file

@ -14,9 +14,8 @@ import com.stevesoltys.seedvault.transport.backup.copyStreamsAndGetHash
import com.stevesoltys.seedvault.transport.backup.getSignatures
import com.stevesoltys.seedvault.transport.backup.isSystemApp
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.flow
import java.io.File
import java.io.IOException
@ -31,7 +30,6 @@ internal class ApkRestore(
private val pm = context.packageManager
@ExperimentalCoroutinesApi
fun restore(token: Long, packageMetadataMap: PackageMetadataMap) = flow {
// filter out packages without APK and get total
val packages = packageMetadataMap.filter { it.value.hasApk() }
@ -40,19 +38,21 @@ internal class ApkRestore(
// queue all packages and emit LiveData
val installResult = MutableInstallResult(total)
packages.forEach { (packageName, _) ->
packages.forEach { (packageName, metadata) ->
progress++
installResult[packageName] = ApkInstallResult(packageName, progress, total, QUEUED)
installResult[packageName] = ApkInstallResult(
packageName = packageName,
progress = progress,
state = QUEUED,
installerPackageName = metadata.installer
)
}
emit(installResult)
// restore individual packages and emit updates
// re-install individual packages and emit updates
for ((packageName, metadata) in packages) {
try {
@Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
restore(token, packageName, metadata, installResult).collect {
emit(it)
}
restore(this, token, packageName, metadata, installResult)
} catch (e: IOException) {
Log.e(TAG, "Error re-installing APK for $packageName.", e)
emit(fail(installResult, packageName))
@ -64,17 +64,19 @@ internal class ApkRestore(
emit(fail(installResult, packageName))
}
}
installResult.isFinished = true
emit(installResult)
}
@ExperimentalCoroutinesApi
@Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
@Suppress("ThrowsCount", "BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
@Throws(IOException::class, SecurityException::class)
private fun restore(
private suspend fun restore(
collector: FlowCollector<InstallResult>,
token: Long,
packageName: String,
metadata: PackageMetadata,
installResult: MutableInstallResult
) = flow {
) {
// create a cache file to write the APK into
val cachedApk = File.createTempFile(packageName, ".apk", context.cacheDir)
// copy APK to cache file and calculate SHA-256 hash while we are at it
@ -102,7 +104,8 @@ internal class ApkRestore(
TAG, "Package $packageName expects version code ${metadata.version}," +
"but has ${packageInfo.longVersionCode}."
)
// TODO should we let this one pass, maybe once we can revert PackageMetadata during backup?
// TODO should we let this one pass,
// maybe once we can revert PackageMetadata during backup?
}
// check signatures
@ -121,13 +124,9 @@ internal class ApkRestore(
val name = pm.getApplicationLabel(appInfo)
installResult.update(packageName) { result ->
result.copy(
state = IN_PROGRESS,
name = name,
icon = icon
)
result.copy(state = IN_PROGRESS, name = name, icon = icon)
}
emit(installResult)
collector.emit(installResult)
// ensure system apps are actually installed and newer system apps as well
if (metadata.system) {
@ -138,16 +137,15 @@ internal class ApkRestore(
if (isOlder || !installedPackageInfo.isSystemApp()) throw NameNotFoundException()
} catch (e: NameNotFoundException) {
Log.w(TAG, "Not installing $packageName because older or not a system app here.")
emit(fail(installResult, packageName))
return@flow
// TODO consider reporting different status here to prevent manual installs
collector.emit(fail(installResult, packageName))
return
}
}
// install APK and emit updates from it
apkInstaller.install(cachedApk, packageName, metadata.installer, installResult)
.collect { result ->
emit(result)
}
val result = apkInstaller.install(cachedApk, packageName, metadata.installer, installResult)
collector.emit(result)
}
private fun fail(installResult: MutableInstallResult, packageName: String): InstallResult {

View file

@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.restore.install
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
@ -14,20 +15,34 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
import com.stevesoltys.seedvault.ui.AppViewHolder
import com.stevesoltys.seedvault.ui.notification.getAppName
internal class InstallProgressAdapter : Adapter<AppInstallViewHolder>() {
internal interface InstallItemListener {
fun onFailedItemClicked(item: ApkInstallResult)
}
internal class InstallProgressAdapter(
private val listener: InstallItemListener
) : Adapter<InstallProgressAdapter.AppInstallViewHolder>() {
private var finished = false
private val finishedComparator = FailedFirstComparator()
private val items = SortedList<ApkInstallResult>(
ApkInstallResult::class.java,
object : SortedListAdapterCallback<ApkInstallResult>(this) {
override fun areItemsTheSame(item1: ApkInstallResult, item2: ApkInstallResult) =
item1.packageName == item2.packageName
override fun areContentsTheSame(oldItem: ApkInstallResult, newItem: ApkInstallResult) =
oldItem == newItem
override fun areContentsTheSame(old: ApkInstallResult, new: ApkInstallResult): Boolean {
// update failed items when finished
return if (finished) new.state != FAILED && old == new
else old == new
}
override fun compare(item1: ApkInstallResult, item2: ApkInstallResult) =
item1.compareTo(item2)
override fun compare(item1: ApkInstallResult, item2: ApkInstallResult): Int {
return if (finished) finishedComparator.compare(item1, item2)
else item1.compareTo(item2)
}
})
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppInstallViewHolder {
@ -45,30 +60,46 @@ internal class InstallProgressAdapter : Adapter<AppInstallViewHolder>() {
fun update(items: Collection<ApkInstallResult>) {
this.items.replaceAll(items)
}
}
internal class AppInstallViewHolder(v: View) : AppViewHolder(v) {
fun bind(item: ApkInstallResult) {
appIcon.setImageDrawable(item.icon)
appName.text = item.name
when (item.state) {
IN_PROGRESS -> {
appStatus.visibility = INVISIBLE
progressBar.visibility = VISIBLE
}
SUCCEEDED -> {
appStatus.setImageResource(R.drawable.ic_check_green)
appStatus.visibility = VISIBLE
progressBar.visibility = INVISIBLE
}
FAILED -> {
appStatus.setImageResource(R.drawable.ic_error_red)
appStatus.visibility = VISIBLE
progressBar.visibility = INVISIBLE
}
QUEUED -> throw AssertionError()
}
fun setFinished() {
finished = true
}
internal inner class AppInstallViewHolder(v: View) : AppViewHolder(v) {
fun bind(item: ApkInstallResult) {
v.setOnClickListener(null)
v.background = null
appIcon.setImageDrawable(item.icon)
appName.text = item.name ?: getAppName(v.context, item.packageName.toString())
appInfo.visibility = GONE
when (item.state) {
IN_PROGRESS -> {
appStatus.visibility = INVISIBLE
progressBar.visibility = VISIBLE
}
SUCCEEDED -> {
appStatus.setImageResource(R.drawable.ic_check_green)
appStatus.visibility = VISIBLE
progressBar.visibility = INVISIBLE
}
FAILED -> {
appStatus.setImageResource(R.drawable.ic_error_red)
appStatus.visibility = VISIBLE
progressBar.visibility = INVISIBLE
if (finished) {
v.background = clickableBackground
v.setOnClickListener {
listener.onFailedItemClicked(item)
}
appInfo.visibility = VISIBLE
appInfo.setText(R.string.restore_installing_tap_to_install)
}
}
QUEUED -> throw AssertionError()
}
}
} // end AppInstallViewHolder
}

View file

@ -1,5 +1,11 @@
package com.stevesoltys.seedvault.restore.install
import android.content.Intent
import android.content.Intent.ACTION_VIEW
import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -7,6 +13,7 @@ import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DividerItemDecoration
@ -17,12 +24,12 @@ import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.RestoreViewModel
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class InstallProgressFragment : Fragment() {
class InstallProgressFragment : Fragment(), InstallItemListener {
private val viewModel: RestoreViewModel by sharedViewModel()
private val layoutManager = LinearLayoutManager(context)
private val adapter = InstallProgressAdapter()
private val adapter = InstallProgressAdapter(this)
private lateinit var progressBar: ProgressBar
private lateinit var titleView: TextView
@ -72,16 +79,59 @@ class InstallProgressFragment : Fragment() {
private fun onInstallResult(installResult: InstallResult) {
// skip this screen, if there are no apps to install
if (installResult.isEmpty()) viewModel.onNextClicked()
if (installResult.isEmpty) viewModel.onNextClicked()
val position = layoutManager.findFirstVisibleItemPosition()
adapter.update(installResult.getNotQueued())
if (position == 0) layoutManager.scrollToPosition(0)
// if finished, treat all still queued apps as failed and resort/redisplay adapter items
if (installResult.isFinished) {
installResult.queuedToFailed()
adapter.setFinished()
}
installResult.getInProgress()?.let {
progressBar.progress = it.progress
progressBar.max = it.total
// update progress bar
progressBar.progress = installResult.progress
progressBar.max = installResult.total
// just update adapter, or perform final action, if finished
if (installResult.isFinished) onFinished(installResult)
else updateAdapter(installResult.getNotQueued())
}
private fun onFinished(installResult: InstallResult) {
if (installResult.hasFailed) {
AlertDialog.Builder(requireContext())
.setIcon(R.drawable.ic_warning)
.setTitle(R.string.restore_installing_error_title)
.setMessage(R.string.restore_installing_error_message)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
}
.setOnDismissListener {
updateAdapter(installResult.getNotQueued())
}
.show()
} else {
updateAdapter(installResult.getNotQueued())
}
}
private fun updateAdapter(items: Collection<ApkInstallResult>) {
val position = layoutManager.findFirstVisibleItemPosition()
adapter.update(items)
if (position == 0) layoutManager.scrollToPosition(0)
}
override fun onFailedItemClicked(item: ApkInstallResult) {
// TODO restrict intent to installer package names to one of:
// * "org.fdroid.fdroid" "org.fdroid.fdroid.privileged"
// * "com.aurora.store"
// * "com.android.vending"
val i = Intent(ACTION_VIEW, Uri.parse("market://details?id=${item.packageName}")).apply {
addFlags(FLAG_ACTIVITY_NEW_TASK)
addFlags(FLAG_ACTIVITY_CLEAR_TOP)
addFlags(FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
// setPackage("com.aurora.store")
}
startActivity(i)
}
}

View file

@ -1,21 +1,38 @@
package com.stevesoltys.seedvault.restore.install
import android.graphics.drawable.Drawable
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
import java.util.concurrent.ConcurrentHashMap
internal interface InstallResult {
/**
* Returns true, if there is no packages to install and false otherwise.
* The number of packages already processed.
*/
fun isEmpty(): Boolean
val progress: Int
/**
* Returns the [ApkInstallResult] of the package currently [IN_PROGRESS]
* or null if there is no such package.
* The total number of packages to be considered for re-install.
*/
fun getInProgress(): ApkInstallResult?
val total: Int
/**
* Is true, if there is no packages to install and false otherwise.
*/
val isEmpty: Boolean
/**
* Is true, if the installation is finished, either because all packages were processed
* or because an unexpected error happened along the way.
* Is false, if the installation is still ongoing.
*/
val isFinished: Boolean
/**
* Is true when one or more packages failed to install.
*/
val hasFailed: Boolean
/**
* Get all [ApkInstallResult]s that are not in state [QUEUED].
@ -23,32 +40,43 @@ internal interface InstallResult {
fun getNotQueued(): Collection<ApkInstallResult>
/**
* Get the [ApkInstallResult] for the given package name or null if none exists.
* Set the set of all [ApkInstallResult]s that are still [QUEUED] to [FAILED].
* This is useful after [isFinished] is true due to an error
* and we need to treat all packages as failed that haven't been processed.
*/
operator fun get(packageName: String): ApkInstallResult?
fun queuedToFailed()
}
internal class MutableInstallResult(initialCapacity: Int) : InstallResult {
internal class MutableInstallResult(override val total: Int) : InstallResult {
private val installResults = ConcurrentHashMap<String, ApkInstallResult>(initialCapacity)
private val installResults = ConcurrentHashMap<String, ApkInstallResult>(total)
override val isEmpty get() = installResults.isEmpty()
override fun isEmpty() = installResults.isEmpty()
override fun getInProgress(): ApkInstallResult? {
val filtered = installResults.filterValues { result -> result.state == IN_PROGRESS }
if (filtered.isEmpty()) return null
check(filtered.size == 1) { "More than one package in progress: ${filtered.keys}" }
return filtered.values.first()
}
@Volatile
override var isFinished = false
override val progress
get() = installResults.count {
val state = it.value.state
state != QUEUED && state != IN_PROGRESS
}
override val hasFailed get() = installResults.any { it.value.state == FAILED }
override fun getNotQueued(): Collection<ApkInstallResult> {
return installResults.filterValues { result -> result.state != QUEUED }.values
}
override fun get(packageName: String) = installResults[packageName]
override fun queuedToFailed() {
installResults.forEach { entry ->
val result = entry.value
if (result.state == QUEUED) installResults[entry.key] = result.copy(state = FAILED)
}
}
operator fun get(packageName: String) = installResults[packageName]
operator fun set(packageName: String, installResult: ApkInstallResult) {
installResults[packageName] = installResult
check(installResults.size <= total) { "Attempting to add more packages than total" }
}
fun update(
@ -63,20 +91,28 @@ internal class MutableInstallResult(initialCapacity: Int) : InstallResult {
}
internal data class ApkInstallResult(
data class ApkInstallResult(
val packageName: CharSequence,
val progress: Int,
val total: Int,
val state: ApkInstallState,
val name: CharSequence? = null,
val icon: Drawable? = null
val icon: Drawable? = null,
val installerPackageName: CharSequence? = null
) : Comparable<ApkInstallResult> {
override fun compareTo(other: ApkInstallResult): Int {
return other.progress.compareTo(progress)
}
}
internal enum class ApkInstallState {
internal class FailedFirstComparator : Comparator<ApkInstallResult> {
override fun compare(a1: ApkInstallResult, a2: ApkInstallResult): Int {
return (if (a1.state == FAILED && a2.state != FAILED) -1
else if (a2.state == FAILED && a1.state != FAILED) 1
else a1.compareTo(a2))
}
}
enum class ApkInstallState {
QUEUED,
IN_PROGRESS,
SUCCEEDED,

View file

@ -113,6 +113,9 @@
<string name="restore_set_error">An error occurred while loading the backups.</string>
<string name="restore_set_empty_result">No suitable backups found at given location.\n\nThis is most likely due to a wrong recovery code or a storage error.</string>
<string name="restore_installing_packages">Re-installing apps</string>
<string name="restore_installing_error_title">Some apps not installed</string>
<string name="restore_installing_error_message">Data can only be restored if an app is installed.\n\nTap failed apps to try to install them manually before proceeding.</string>
<string name="restore_installing_tap_to_install">Tap to install</string>
<string name="restore_next">Next</string>
<string name="restore_restoring">Restoring backup</string>
<string name="restore_magic_package">System package manager</string>

View file

@ -23,9 +23,10 @@ import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
@ -80,16 +81,22 @@ internal class ApkRestoreTest : TransportTest() {
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) {
0 -> {
val result = value[packageName] ?: fail()
val result = value[packageName]
assertEquals(QUEUED, result.state)
assertEquals(1, result.progress)
assertEquals(1, result.total)
assertEquals(1, value.total)
}
1 -> {
val result = value[packageName] ?: fail()
val result = value[packageName]
assertEquals(FAILED, result.state)
assertTrue(value.hasFailed)
assertFalse(value.isFinished)
}
else -> fail()
2 -> {
assertTrue(value.hasFailed)
assertTrue(value.isFinished)
}
else -> fail("more values emitted")
}
}
}
@ -106,16 +113,21 @@ internal class ApkRestoreTest : TransportTest() {
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) {
0 -> {
val result = value[packageName] ?: fail()
val result = value[packageName]
assertEquals(QUEUED, result.state)
assertEquals(1, result.progress)
assertEquals(1, result.total)
assertEquals(1, value.total)
}
1 -> {
val result = value[packageName] ?: fail()
val result = value[packageName]
assertEquals(FAILED, result.state)
assertTrue(value.hasFailed)
}
else -> fail()
2 -> {
assertTrue(value.hasFailed)
assertTrue(value.isFinished)
}
else -> fail("more values emitted")
}
}
}
@ -132,34 +144,37 @@ internal class ApkRestoreTest : TransportTest() {
)
} returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
every {
apkInstaller.install(
any(),
packageName,
installerName,
any()
)
coEvery {
apkInstaller.install(any(), packageName, installerName, any())
} throws SecurityException()
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) {
0 -> {
val result = value[packageName] ?: fail()
val result = value[packageName]
assertEquals(QUEUED, result.state)
assertEquals(1, result.progress)
assertEquals(1, result.total)
assertEquals(installerName, result.installerPackageName)
assertEquals(1, value.total)
}
1 -> {
val result = value[packageName] ?: fail()
val result = value[packageName]
assertEquals(IN_PROGRESS, result.state)
assertEquals(appName, result.name)
assertEquals(icon, result.icon)
assertFalse(value.hasFailed)
}
2 -> {
val result = value[packageName] ?: fail()
val result = value[packageName]
assertTrue(value.hasFailed)
assertEquals(FAILED, result.state)
assertFalse(value.isFinished)
}
else -> fail()
3 -> {
assertTrue(value.hasFailed, "1")
assertTrue(value.isFinished, "2")
}
else -> fail("more values emitted")
}
}
}
@ -171,7 +186,6 @@ internal class ApkRestoreTest : TransportTest() {
packageName, ApkInstallResult(
packageName,
progress = 1,
total = 1,
state = SUCCEEDED
)
)
@ -187,30 +201,34 @@ internal class ApkRestoreTest : TransportTest() {
)
} returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
every { apkInstaller.install(any(), packageName, installerName, any()) } returns flowOf(
installResult
)
coEvery {
apkInstaller.install(any(), packageName, installerName, any())
} returns installResult
var i = 0
apkRestore.restore(token, packageMetadataMap).collect { value ->
when (i) {
0 -> {
val result = value[packageName] ?: fail()
val result = value[packageName]
assertEquals(QUEUED, result.state)
assertEquals(1, result.progress)
assertEquals(1, result.total)
assertEquals(1, value.total)
}
1 -> {
val result = value[packageName] ?: fail()
val result = value[packageName]
assertEquals(IN_PROGRESS, result.state)
assertEquals(appName, result.name)
assertEquals(icon, result.icon)
}
2 -> {
val result = value[packageName] ?: fail()
val result = value[packageName]
assertEquals(SUCCEEDED, result.state)
}
else -> fail()
3 -> {
assertFalse(value.hasFailed)
assertTrue(value.isFinished)
}
else -> fail("more values emitted")
}
i++
}
@ -246,46 +264,48 @@ internal class ApkRestoreTest : TransportTest() {
val installResult = MutableInstallResult(1).apply {
set(
packageName,
ApkInstallResult(packageName, progress = 1, total = 1, state = SUCCEEDED)
ApkInstallResult(packageName, progress = 1, state = SUCCEEDED)
)
}
every {
apkInstaller.install(
any(),
packageName,
installerName,
any()
)
} returns flowOf(installResult)
coEvery {
apkInstaller.install(any(), packageName, installerName, any())
} returns installResult
}
var i = 0
apkRestore.restore(token, packageMetadataMap).collect { value ->
apkRestore.restore(token, packageMetadataMap).collectIndexed { i, value ->
when (i) {
0 -> {
val result = value[packageName] ?: fail()
val result = value[packageName]
assertEquals(QUEUED, result.state)
assertEquals(1, result.progress)
assertEquals(1, result.total)
assertEquals(1, value.total)
}
1 -> {
val result = value[packageName] ?: fail()
val result = value[packageName]
assertEquals(IN_PROGRESS, result.state)
assertEquals(appName, result.name)
assertEquals(icon, result.icon)
}
2 -> {
val result = value[packageName] ?: fail()
val result = value[packageName]
if (willFail) {
assertEquals(FAILED, result.state)
} else {
assertEquals(SUCCEEDED, result.state)
}
assertEquals(willFail, value.hasFailed)
}
else -> fail()
3 -> {
assertEquals(willFail, value.hasFailed)
assertTrue(value.isFinished)
}
else -> fail("more values emitted")
}
i++
}
}
}
private operator fun InstallResult.get(packageName: String): ApkInstallResult {
return (this as MutableInstallResult)[packageName] ?: fail("$packageName not found")
}