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.Transformations.switchMap
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.stevesoltys.seedvault.ui.AppBackupState
import com.stevesoltys.seedvault.BackupMonitor import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R 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.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED 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
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_ALLOWED import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_ALLOWED
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_INSTALLED 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.IN_PROGRESS
import com.stevesoltys.seedvault.ui.AppBackupState.NOT_YET_BACKED_UP import com.stevesoltys.seedvault.ui.AppBackupState.NOT_YET_BACKED_UP
import com.stevesoltys.seedvault.ui.AppBackupState.SUCCEEDED 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.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import com.stevesoltys.seedvault.ui.notification.getAppName import com.stevesoltys.seedvault.ui.notification.getAppName
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
@ -87,7 +86,6 @@ internal class RestoreViewModel(
internal val installResult: LiveData<InstallResult> = internal val installResult: LiveData<InstallResult> =
switchMap(mChosenRestorableBackup) { backup -> switchMap(mChosenRestorableBackup) { backup ->
@Suppress("EXPERIMENTAL_API_USAGE")
getInstallResult(backup) getInstallResult(backup)
} }
@ -151,8 +149,8 @@ internal class RestoreViewModel(
closeSession() closeSession()
} }
@ExperimentalCoroutinesApi
private fun getInstallResult(restorableBackup: RestorableBackup): LiveData<InstallResult> { private fun getInstallResult(restorableBackup: RestorableBackup): LiveData<InstallResult> {
@Suppress("EXPERIMENTAL_API_USAGE")
return apkRestore.restore(restorableBackup.token, restorableBackup.packageMetadataMap) return apkRestore.restore(restorableBackup.token, restorableBackup.packageMetadataMap)
.onStart { .onStart {
Log.d(TAG, "Start InstallResult Flow") Log.d(TAG, "Start InstallResult Flow")
@ -163,7 +161,9 @@ internal class RestoreViewModel(
mNextButtonEnabled.postValue(true) mNextButtonEnabled.postValue(true)
} }
.flowOn(ioDispatcher) .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() { internal fun onNextClicked() {
@ -336,7 +336,8 @@ internal class RestoreViewModel(
/** /**
* The restore operation has begun. * 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) { override fun restoreStarting(numPackages: Int) {
// noop // 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.FAILED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import kotlin.coroutines.resume
private val TAG: String = ApkInstaller::class.java.simpleName 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 pm: PackageManager = context.packageManager
private val installer: PackageInstaller = pm.packageInstaller private val installer: PackageInstaller = pm.packageInstaller
@ExperimentalCoroutinesApi
@Throws(IOException::class, SecurityException::class) @Throws(IOException::class, SecurityException::class)
internal fun install( internal suspend fun install(
cachedApk: File, cachedApk: File,
packageName: String, packageName: String,
installerPackageName: String?, installerPackageName: String?,
installResult: MutableInstallResult installResult: MutableInstallResult
) = callbackFlow { ) = suspendCancellableCoroutine<InstallResult> { cont ->
val broadcastReceiver = object : BroadcastReceiver() { val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, i: Intent) { override fun onReceive(context: Context, i: Intent) {
if (i.action != BROADCAST_ACTION) return if (i.action != BROADCAST_ACTION) return
offer(onBroadcastReceived(i, packageName, cachedApk, installResult)) context.unregisterReceiver(this)
close() cont.resume(onBroadcastReceived(i, packageName, cachedApk, installResult))
} }
} }
context.registerReceiver(broadcastReceiver, IntentFilter(BROADCAST_ACTION)) context.registerReceiver(broadcastReceiver, IntentFilter(BROADCAST_ACTION))
cont.invokeOnCancellation { context.unregisterReceiver(broadcastReceiver) }
install(cachedApk, installerPackageName) install(cachedApk, installerPackageName)
awaitClose { context.unregisterReceiver(broadcastReceiver) }
} }
private fun install(cachedApk: File, installerPackageName: String?) { 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. // Don't set more sessionParams intentionally here.
// We saw strange permission issues when doing setInstallReason() or setting installFlags. // 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 session = installer.openSession(installer.createSession(sessionParams))
val sizeBytes = cachedApk.length() val sizeBytes = cachedApk.length()
session.use { s -> session.use { s ->
@ -110,6 +106,7 @@ internal class ApkInstaller(private val context: Context) {
} }
// update status and offer result // 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 val status = if (success) SUCCEEDED else FAILED
return installResult.update(packageName) { it.copy(state = status) } 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.getSignatures
import com.stevesoltys.seedvault.transport.backup.isSystemApp import com.stevesoltys.seedvault.transport.backup.isSystemApp
import com.stevesoltys.seedvault.transport.restore.RestorePlugin import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@ -31,7 +30,6 @@ internal class ApkRestore(
private val pm = context.packageManager private val pm = context.packageManager
@ExperimentalCoroutinesApi
fun restore(token: Long, packageMetadataMap: PackageMetadataMap) = flow { fun restore(token: Long, packageMetadataMap: PackageMetadataMap) = flow {
// filter out packages without APK and get total // filter out packages without APK and get total
val packages = packageMetadataMap.filter { it.value.hasApk() } val packages = packageMetadataMap.filter { it.value.hasApk() }
@ -40,19 +38,21 @@ internal class ApkRestore(
// queue all packages and emit LiveData // queue all packages and emit LiveData
val installResult = MutableInstallResult(total) val installResult = MutableInstallResult(total)
packages.forEach { (packageName, _) -> packages.forEach { (packageName, metadata) ->
progress++ progress++
installResult[packageName] = ApkInstallResult(packageName, progress, total, QUEUED) installResult[packageName] = ApkInstallResult(
packageName = packageName,
progress = progress,
state = QUEUED,
installerPackageName = metadata.installer
)
} }
emit(installResult) emit(installResult)
// restore individual packages and emit updates // re-install individual packages and emit updates
for ((packageName, metadata) in packages) { for ((packageName, metadata) in packages) {
try { try {
@Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO restore(this, token, packageName, metadata, installResult)
restore(token, packageName, metadata, installResult).collect {
emit(it)
}
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error re-installing APK for $packageName.", e) Log.e(TAG, "Error re-installing APK for $packageName.", e)
emit(fail(installResult, packageName)) emit(fail(installResult, packageName))
@ -64,17 +64,19 @@ internal class ApkRestore(
emit(fail(installResult, packageName)) emit(fail(installResult, packageName))
} }
} }
installResult.isFinished = true
emit(installResult)
} }
@ExperimentalCoroutinesApi @Suppress("ThrowsCount", "BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
@Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
@Throws(IOException::class, SecurityException::class) @Throws(IOException::class, SecurityException::class)
private fun restore( private suspend fun restore(
collector: FlowCollector<InstallResult>,
token: Long, token: Long,
packageName: String, packageName: String,
metadata: PackageMetadata, metadata: PackageMetadata,
installResult: MutableInstallResult installResult: MutableInstallResult
) = flow { ) {
// create a cache file to write the APK into // create a cache file to write the APK into
val cachedApk = File.createTempFile(packageName, ".apk", context.cacheDir) val cachedApk = File.createTempFile(packageName, ".apk", context.cacheDir)
// copy APK to cache file and calculate SHA-256 hash while we are at it // copy APK to cache file and calculate SHA-256 hash while we are at it
@ -102,7 +104,8 @@ internal class ApkRestore(
TAG, "Package $packageName expects version code ${metadata.version}," + TAG, "Package $packageName expects version code ${metadata.version}," +
"but has ${packageInfo.longVersionCode}." "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 // check signatures
@ -121,13 +124,9 @@ internal class ApkRestore(
val name = pm.getApplicationLabel(appInfo) val name = pm.getApplicationLabel(appInfo)
installResult.update(packageName) { result -> installResult.update(packageName) { result ->
result.copy( result.copy(state = IN_PROGRESS, name = name, icon = icon)
state = IN_PROGRESS,
name = name,
icon = icon
)
} }
emit(installResult) collector.emit(installResult)
// ensure system apps are actually installed and newer system apps as well // ensure system apps are actually installed and newer system apps as well
if (metadata.system) { if (metadata.system) {
@ -138,16 +137,15 @@ internal class ApkRestore(
if (isOlder || !installedPackageInfo.isSystemApp()) throw NameNotFoundException() if (isOlder || !installedPackageInfo.isSystemApp()) throw NameNotFoundException()
} catch (e: NameNotFoundException) { } catch (e: NameNotFoundException) {
Log.w(TAG, "Not installing $packageName because older or not a system app here.") Log.w(TAG, "Not installing $packageName because older or not a system app here.")
emit(fail(installResult, packageName)) // TODO consider reporting different status here to prevent manual installs
return@flow collector.emit(fail(installResult, packageName))
return
} }
} }
// install APK and emit updates from it // install APK and emit updates from it
apkInstaller.install(cachedApk, packageName, metadata.installer, installResult) val result = apkInstaller.install(cachedApk, packageName, metadata.installer, installResult)
.collect { result -> collector.emit(result)
emit(result)
}
} }
private fun fail(installResult: MutableInstallResult, packageName: String): InstallResult { 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.LayoutInflater
import android.view.View import android.view.View
import android.view.View.GONE
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.view.ViewGroup import android.view.ViewGroup
@ -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.QUEUED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
import com.stevesoltys.seedvault.ui.AppViewHolder import com.stevesoltys.seedvault.ui.AppViewHolder
import com.stevesoltys.seedvault.ui.notification.getAppName
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>( private val items = SortedList<ApkInstallResult>(
ApkInstallResult::class.java, ApkInstallResult::class.java,
object : SortedListAdapterCallback<ApkInstallResult>(this) { object : SortedListAdapterCallback<ApkInstallResult>(this) {
override fun areItemsTheSame(item1: ApkInstallResult, item2: ApkInstallResult) = override fun areItemsTheSame(item1: ApkInstallResult, item2: ApkInstallResult) =
item1.packageName == item2.packageName item1.packageName == item2.packageName
override fun areContentsTheSame(oldItem: ApkInstallResult, newItem: ApkInstallResult) = override fun areContentsTheSame(old: ApkInstallResult, new: ApkInstallResult): Boolean {
oldItem == newItem // update failed items when finished
return if (finished) new.state != FAILED && old == new
else old == new
}
override fun compare(item1: ApkInstallResult, item2: ApkInstallResult) = override fun compare(item1: ApkInstallResult, item2: ApkInstallResult): Int {
item1.compareTo(item2) return if (finished) finishedComparator.compare(item1, item2)
else item1.compareTo(item2)
}
}) })
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppInstallViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppInstallViewHolder {
@ -45,30 +60,46 @@ internal class InstallProgressAdapter : Adapter<AppInstallViewHolder>() {
fun update(items: Collection<ApkInstallResult>) { fun update(items: Collection<ApkInstallResult>) {
this.items.replaceAll(items) this.items.replaceAll(items)
} }
}
internal class AppInstallViewHolder(v: View) : AppViewHolder(v) { fun setFinished() {
finished = true
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()
}
} }
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 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.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@ -7,6 +13,7 @@ import android.view.ViewGroup
import android.widget.Button import android.widget.Button
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
@ -17,12 +24,12 @@ import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.RestoreViewModel import com.stevesoltys.seedvault.restore.RestoreViewModel
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class InstallProgressFragment : Fragment() { class InstallProgressFragment : Fragment(), InstallItemListener {
private val viewModel: RestoreViewModel by sharedViewModel() private val viewModel: RestoreViewModel by sharedViewModel()
private val layoutManager = LinearLayoutManager(context) private val layoutManager = LinearLayoutManager(context)
private val adapter = InstallProgressAdapter() private val adapter = InstallProgressAdapter(this)
private lateinit var progressBar: ProgressBar private lateinit var progressBar: ProgressBar
private lateinit var titleView: TextView private lateinit var titleView: TextView
@ -72,16 +79,59 @@ class InstallProgressFragment : Fragment() {
private fun onInstallResult(installResult: InstallResult) { private fun onInstallResult(installResult: InstallResult) {
// skip this screen, if there are no apps to install // skip this screen, if there are no apps to install
if (installResult.isEmpty()) viewModel.onNextClicked() if (installResult.isEmpty) viewModel.onNextClicked()
val position = layoutManager.findFirstVisibleItemPosition() // if finished, treat all still queued apps as failed and resort/redisplay adapter items
adapter.update(installResult.getNotQueued()) if (installResult.isFinished) {
if (position == 0) layoutManager.scrollToPosition(0) installResult.queuedToFailed()
adapter.setFinished()
}
installResult.getInProgress()?.let { // update progress bar
progressBar.progress = it.progress progressBar.progress = installResult.progress
progressBar.max = it.total 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 package com.stevesoltys.seedvault.restore.install
import android.graphics.drawable.Drawable 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.IN_PROGRESS
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
internal interface InstallResult { 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] * The total number of packages to be considered for re-install.
* or null if there is no such package.
*/ */
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]. * Get all [ApkInstallResult]s that are not in state [QUEUED].
@ -23,32 +40,43 @@ internal interface InstallResult {
fun getNotQueued(): Collection<ApkInstallResult> 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() @Volatile
override var isFinished = false
override fun getInProgress(): ApkInstallResult? { override val progress
val filtered = installResults.filterValues { result -> result.state == IN_PROGRESS } get() = installResults.count {
if (filtered.isEmpty()) return null val state = it.value.state
check(filtered.size == 1) { "More than one package in progress: ${filtered.keys}" } state != QUEUED && state != IN_PROGRESS
return filtered.values.first() }
} override val hasFailed get() = installResults.any { it.value.state == FAILED }
override fun getNotQueued(): Collection<ApkInstallResult> { override fun getNotQueued(): Collection<ApkInstallResult> {
return installResults.filterValues { result -> result.state != QUEUED }.values 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) { operator fun set(packageName: String, installResult: ApkInstallResult) {
installResults[packageName] = installResult installResults[packageName] = installResult
check(installResults.size <= total) { "Attempting to add more packages than total" }
} }
fun update( fun update(
@ -63,20 +91,28 @@ internal class MutableInstallResult(initialCapacity: Int) : InstallResult {
} }
internal data class ApkInstallResult( data class ApkInstallResult(
val packageName: CharSequence, val packageName: CharSequence,
val progress: Int, val progress: Int,
val total: Int,
val state: ApkInstallState, val state: ApkInstallState,
val name: CharSequence? = null, val name: CharSequence? = null,
val icon: Drawable? = null val icon: Drawable? = null,
val installerPackageName: CharSequence? = null
) : Comparable<ApkInstallResult> { ) : Comparable<ApkInstallResult> {
override fun compareTo(other: ApkInstallResult): Int { override fun compareTo(other: ApkInstallResult): Int {
return other.progress.compareTo(progress) 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, QUEUED,
IN_PROGRESS, IN_PROGRESS,
SUCCEEDED, SUCCEEDED,

View file

@ -113,6 +113,9 @@
<string name="restore_set_error">An error occurred while loading the backups.</string> <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_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_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_next">Next</string>
<string name="restore_restoring">Restoring backup</string> <string name="restore_restoring">Restoring backup</string>
<string name="restore_magic_package">System package manager</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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectIndexed import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.api.io.TempDir
@ -80,16 +81,22 @@ internal class ApkRestoreTest : TransportTest() {
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value -> apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) { when (index) {
0 -> { 0 -> {
val result = value[packageName] ?: fail() val result = value[packageName]
assertEquals(QUEUED, result.state) assertEquals(QUEUED, result.state)
assertEquals(1, result.progress) assertEquals(1, result.progress)
assertEquals(1, result.total) assertEquals(1, value.total)
} }
1 -> { 1 -> {
val result = value[packageName] ?: fail() val result = value[packageName]
assertEquals(FAILED, result.state) 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 -> apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) { when (index) {
0 -> { 0 -> {
val result = value[packageName] ?: fail() val result = value[packageName]
assertEquals(QUEUED, result.state) assertEquals(QUEUED, result.state)
assertEquals(1, result.progress) assertEquals(1, result.progress)
assertEquals(1, result.total) assertEquals(1, value.total)
} }
1 -> { 1 -> {
val result = value[packageName] ?: fail() val result = value[packageName]
assertEquals(FAILED, result.state) 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 } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
every { coEvery {
apkInstaller.install( apkInstaller.install(any(), packageName, installerName, any())
any(),
packageName,
installerName,
any()
)
} throws SecurityException() } throws SecurityException()
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value -> apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) { when (index) {
0 -> { 0 -> {
val result = value[packageName] ?: fail() val result = value[packageName]
assertEquals(QUEUED, result.state) assertEquals(QUEUED, result.state)
assertEquals(1, result.progress) assertEquals(1, result.progress)
assertEquals(1, result.total) assertEquals(installerName, result.installerPackageName)
assertEquals(1, value.total)
} }
1 -> { 1 -> {
val result = value[packageName] ?: fail() val result = value[packageName]
assertEquals(IN_PROGRESS, result.state) assertEquals(IN_PROGRESS, result.state)
assertEquals(appName, result.name) assertEquals(appName, result.name)
assertEquals(icon, result.icon) assertEquals(icon, result.icon)
assertFalse(value.hasFailed)
} }
2 -> { 2 -> {
val result = value[packageName] ?: fail() val result = value[packageName]
assertTrue(value.hasFailed)
assertEquals(FAILED, result.state) 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, ApkInstallResult(
packageName, packageName,
progress = 1, progress = 1,
total = 1,
state = SUCCEEDED state = SUCCEEDED
) )
) )
@ -187,30 +201,34 @@ internal class ApkRestoreTest : TransportTest() {
) )
} returns icon } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
every { apkInstaller.install(any(), packageName, installerName, any()) } returns flowOf( coEvery {
installResult apkInstaller.install(any(), packageName, installerName, any())
) } returns installResult
var i = 0 var i = 0
apkRestore.restore(token, packageMetadataMap).collect { value -> apkRestore.restore(token, packageMetadataMap).collect { value ->
when (i) { when (i) {
0 -> { 0 -> {
val result = value[packageName] ?: fail() val result = value[packageName]
assertEquals(QUEUED, result.state) assertEquals(QUEUED, result.state)
assertEquals(1, result.progress) assertEquals(1, result.progress)
assertEquals(1, result.total) assertEquals(1, value.total)
} }
1 -> { 1 -> {
val result = value[packageName] ?: fail() val result = value[packageName]
assertEquals(IN_PROGRESS, result.state) assertEquals(IN_PROGRESS, result.state)
assertEquals(appName, result.name) assertEquals(appName, result.name)
assertEquals(icon, result.icon) assertEquals(icon, result.icon)
} }
2 -> { 2 -> {
val result = value[packageName] ?: fail() val result = value[packageName]
assertEquals(SUCCEEDED, result.state) assertEquals(SUCCEEDED, result.state)
} }
else -> fail() 3 -> {
assertFalse(value.hasFailed)
assertTrue(value.isFinished)
}
else -> fail("more values emitted")
} }
i++ i++
} }
@ -246,46 +264,48 @@ internal class ApkRestoreTest : TransportTest() {
val installResult = MutableInstallResult(1).apply { val installResult = MutableInstallResult(1).apply {
set( set(
packageName, packageName,
ApkInstallResult(packageName, progress = 1, total = 1, state = SUCCEEDED) ApkInstallResult(packageName, progress = 1, state = SUCCEEDED)
) )
} }
every { coEvery {
apkInstaller.install( apkInstaller.install(any(), packageName, installerName, any())
any(), } returns installResult
packageName,
installerName,
any()
)
} returns flowOf(installResult)
} }
var i = 0 apkRestore.restore(token, packageMetadataMap).collectIndexed { i, value ->
apkRestore.restore(token, packageMetadataMap).collect { value ->
when (i) { when (i) {
0 -> { 0 -> {
val result = value[packageName] ?: fail() val result = value[packageName]
assertEquals(QUEUED, result.state) assertEquals(QUEUED, result.state)
assertEquals(1, result.progress) assertEquals(1, result.progress)
assertEquals(1, result.total) assertEquals(1, value.total)
} }
1 -> { 1 -> {
val result = value[packageName] ?: fail() val result = value[packageName]
assertEquals(IN_PROGRESS, result.state) assertEquals(IN_PROGRESS, result.state)
assertEquals(appName, result.name) assertEquals(appName, result.name)
assertEquals(icon, result.icon) assertEquals(icon, result.icon)
} }
2 -> { 2 -> {
val result = value[packageName] ?: fail() val result = value[packageName]
if (willFail) { if (willFail) {
assertEquals(FAILED, result.state) assertEquals(FAILED, result.state)
} else { } else {
assertEquals(SUCCEEDED, result.state) 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")
}