Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Torsten Grote authored and Chirayu Desai committed Oct 13, 2020
1 parent 747384f commit d6cb34c
Show file tree
Hide file tree
Showing 8 changed files with 289 additions and 153 deletions.
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -87,7 +86,6 @@ internal class RestoreViewModel(

internal val installResult: LiveData<InstallResult> =
switchMap(mChosenRestorableBackup) { backup ->
@Suppress("EXPERIMENTAL_API_USAGE")
getInstallResult(backup)
}

Expand Down Expand Up @@ -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")
Expand All @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
Expand Up @@ -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

Expand All @@ -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?) {
Expand All @@ -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 ->
Expand Down Expand Up @@ -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) }
}
Expand Down
Expand Up @@ -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
Expand All @@ -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() }
Expand All @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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 {
Expand Down
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 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
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()
}
QUEUED -> throw AssertionError()
}
}
} // end AppInstallViewHolder

}

0 comments on commit d6cb34c

Please sign in to comment.