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:
parent
747384fb59
commit
d6cb34c211
8 changed files with 293 additions and 157 deletions
|
@ -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
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue