Re-install backed-up APKs before restoring from backup

This commit is contained in:
Torsten Grote 2019-12-20 13:55:38 -03:00
parent 569e3db385
commit 7605762631
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
24 changed files with 845 additions and 97 deletions

View file

@ -14,14 +14,15 @@ A backup application for the [Android Open Source Project](https://source.androi
AOSP.
## What makes this different?
This application is compiled with the operating system and does not require a rooted device for use. It uses the same
internal APIs as `adb backup` and requires a minimal number of permissions to achieve this.
This application is compiled with the operating system and does not require a rooted device for use.
It uses the same internal APIs as `adb backup` which is deprecated and thus needs a replacement.
## Permissions
* `android.permission.BACKUP` to back up application data.
* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots.
* `android.permission.MANAGE_USB` to access the serial number of USB mass storage devices.
* `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings.
* `android.permission.INSTALL_PACKAGES` to re-install apps when restoring from backup.
## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/stevesoltys/seedvault.

View file

@ -119,6 +119,8 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.1.0'
implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0-rc03'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha'

View file

@ -24,6 +24,11 @@
android:name="android.permission.WRITE_SECURE_SETTINGS"
tools:ignore="ProtectedPermissions" />
<!-- This is needed to re-install backed-up packages when restoring from backup -->
<uses-permission
android:name="android.permission.INSTALL_PACKAGES"
tools:ignore="ProtectedPermissions" />
<application
android:name=".App"
android:allowBackup="false"

View file

@ -40,7 +40,7 @@ class App : Application() {
viewModel { RecoveryCodeViewModel(this@App, get()) }
viewModel { BackupStorageViewModel(this@App, get(), get()) }
viewModel { RestoreStorageViewModel(this@App, get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) }
}
override fun onCreate() {

View file

@ -9,7 +9,9 @@ import com.stevesoltys.seedvault.metadata.EncryptedBackupMetadata
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName
@ -84,6 +86,13 @@ internal class DocumentsProviderRestorePlugin(
return backupSets
}
@Throws(IOException::class)
override fun getApkInputStream(token: Long, packageName: String): InputStream {
val setDir = storage.getSetDir(token) ?: throw IOException()
val file = setDir.findFile("$packageName.apk") ?: throw FileNotFoundException()
return storage.getInputStream(file)
}
}
class BackupSet(val token: Long, val metadataFile: DocumentFile)

View file

@ -0,0 +1,48 @@
package com.stevesoltys.seedvault.restore
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.transport.restore.InstallResult
import com.stevesoltys.seedvault.transport.restore.getInProgress
import kotlinx.android.synthetic.main.fragment_install_progress.*
import kotlinx.android.synthetic.main.fragment_restore_progress.backupNameView
import kotlinx.android.synthetic.main.fragment_restore_progress.currentPackageView
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class InstallProgressFragment : Fragment() {
private val viewModel: RestoreViewModel by sharedViewModel()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_install_progress, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel.chosenRestorableBackup.observe(this, Observer { restorableBackup ->
backupNameView.text = restorableBackup.name
})
viewModel.installResult.observe(this, Observer { result ->
onInstallResult(result)
})
}
private fun onInstallResult(installResult: InstallResult) {
installResult.getInProgress()?.let { result ->
currentPackageView.text = result.name
result.icon?.let { currentPackageImageView.setImageDrawable(it) }
progressBar.progress = result.progress
progressBar.max = result.total
}
// TODO add finished apps to list of (failed?) apps and continue on button press
}
}

View file

@ -0,0 +1,22 @@
package com.stevesoltys.seedvault.restore
import android.app.backup.RestoreSet
import com.stevesoltys.seedvault.metadata.BackupMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
data class RestorableBackup(private val restoreSet: RestoreSet,
private val backupMetadata: BackupMetadata) {
val name: String
get() = restoreSet.name
val token: Long
get() = restoreSet.token
val time: Long
get() = backupMetadata.time
val packageMetadataMap: PackageMetadataMap
get() = backupMetadata.packageMetadataMap
}

View file

@ -2,8 +2,10 @@ package com.stevesoltys.seedvault.restore
import android.os.Bundle
import androidx.annotation.CallSuper
import androidx.lifecycle.Observer
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
import com.stevesoltys.seedvault.ui.LiveEventHandler
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
@ -21,8 +23,12 @@ class RestoreActivity : RequireProvisioningActivity() {
setContentView(R.layout.activity_fragment_container)
viewModel.chosenRestoreSet.observe(this, Observer { set ->
if (set != null) showFragment(RestoreProgressFragment())
viewModel.displayFragment.observeEvent(this, LiveEventHandler { fragment ->
when (fragment) {
RESTORE_APPS -> showFragment(InstallProgressFragment())
RESTORE_BACKUP -> showFragment(RestoreProgressFragment())
else -> throw AssertionError()
}
})
if (savedInstanceState == null) {

View file

@ -8,20 +8,18 @@ import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
import androidx.core.content.ContextCompat.getColor
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.getAppName
import com.stevesoltys.seedvault.isDebugBuild
import com.stevesoltys.seedvault.settings.SettingsManager
import kotlinx.android.synthetic.main.fragment_restore_progress.*
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class RestoreProgressFragment : Fragment() {
private val viewModel: RestoreViewModel by sharedViewModel()
private val settingsManager: SettingsManager by inject()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
@ -34,8 +32,8 @@ class RestoreProgressFragment : Fragment() {
// decryption will fail when the device is locked, so keep the screen on to prevent locking
requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON)
viewModel.chosenRestoreSet.observe(this, Observer { set ->
backupNameView.text = set.device
viewModel.chosenRestorableBackup.observe(this, Observer { restorableBackup ->
backupNameView.text = restorableBackup.name
})
viewModel.restoreProgress.observe(this, Observer { currentPackage ->
@ -44,22 +42,14 @@ class RestoreProgressFragment : Fragment() {
currentPackageView.text = getString(R.string.restore_current_package, displayName)
})
viewModel.restoreFinished.observe(this, Observer { finished ->
viewModel.restoreBackupResult.observe(this, Observer { finished ->
progressBar.visibility = INVISIBLE
button.visibility = VISIBLE
if (finished == 0) {
// success
currentPackageView.text = getString(R.string.restore_finished_success)
warningView.text = if (settingsManager.getStorage()?.isUsb == true) {
getString(R.string.restore_finished_warning_only_installed, getString(R.string.restore_finished_warning_ejectable))
} else {
getString(R.string.restore_finished_warning_only_installed, null)
}
warningView.visibility = VISIBLE
if (finished.hasError()) {
currentPackageView.text = finished.errorMsg
currentPackageView.setTextColor(getColor(requireContext(), R.color.red))
} else {
// error
currentPackageView.text = getString(R.string.restore_finished_error)
currentPackageView.setTextColor(warningView.textColors)
currentPackageView.text = getString(R.string.restore_finished_success)
}
activity?.window?.clearFlags(FLAG_KEEP_SCREEN_ON)
})

View file

@ -1,6 +1,6 @@
package com.stevesoltys.seedvault.restore
import android.app.backup.RestoreSet
import android.text.format.DateUtils.*
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@ -11,8 +11,8 @@ import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.RestoreSetAdapter.RestoreSetViewHolder
internal class RestoreSetAdapter(
private val listener: RestoreSetClickListener,
private val items: Array<out RestoreSet>) : Adapter<RestoreSetViewHolder>() {
private val listener: RestorableBackupClickListener,
private val items: List<RestorableBackup>) : Adapter<RestoreSetViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RestoreSetViewHolder {
val v = LayoutInflater.from(parent.context)
@ -31,10 +31,18 @@ internal class RestoreSetAdapter(
private val titleView = v.findViewById<TextView>(R.id.titleView)
private val subtitleView = v.findViewById<TextView>(R.id.subtitleView)
internal fun bind(item: RestoreSet) {
v.setOnClickListener { listener.onRestoreSetClicked(item) }
internal fun bind(item: RestorableBackup) {
v.setOnClickListener { listener.onRestorableBackupClicked(item) }
titleView.text = item.name
subtitleView.text = "Android Backup" // TODO change to backup date when available
val lastBackup = getRelativeTime(item.time)
val setup = getRelativeTime(item.token)
subtitleView.text = v.context.getString(R.string.restore_restore_set_times, lastBackup, setup)
}
private fun getRelativeTime(time: Long): CharSequence {
val now = System.currentTimeMillis()
return getRelativeTimeSpanString(time, now, HOUR_IN_MILLIS, FORMAT_ABBREV_RELATIVE)
}
}

View file

@ -1,12 +1,12 @@
package com.stevesoltys.seedvault.restore
import android.app.backup.RestoreSet
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import com.stevesoltys.seedvault.R
@ -25,7 +25,10 @@ class RestoreSetFragment : Fragment() {
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel.restoreSets.observe(this, Observer { result -> onRestoreSetsLoaded(result) })
// decryption will fail when the device is locked, so keep the screen on to prevent locking
requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON)
viewModel.restoreSetResults.observe(this, Observer { result -> onRestoreResultsLoaded(result) })
backView.setOnClickListener { requireActivity().finishAfterTransition() }
}
@ -37,24 +40,24 @@ class RestoreSetFragment : Fragment() {
}
}
private fun onRestoreSetsLoaded(result: RestoreSetResult) {
if (result.hasError()) {
private fun onRestoreResultsLoaded(results: RestoreSetResult) {
if (results.hasError()) {
errorView.visibility = VISIBLE
listView.visibility = INVISIBLE
progressBar.visibility = INVISIBLE
errorView.text = result.errorMsg
errorView.text = results.errorMsg
} else {
errorView.visibility = INVISIBLE
listView.visibility = VISIBLE
progressBar.visibility = INVISIBLE
listView.adapter = RestoreSetAdapter(viewModel, result.sets)
listView.adapter = RestoreSetAdapter(viewModel, results.restorableBackups)
}
}
}
internal interface RestoreSetClickListener {
fun onRestoreSetClicked(set: RestoreSet)
internal interface RestorableBackupClickListener {
fun onRestorableBackupClicked(restorableBackup: RestorableBackup)
}

View file

@ -5,18 +5,39 @@ import android.app.backup.IBackupManager
import android.app.backup.IRestoreObserver
import android.app.backup.IRestoreSession
import android.app.backup.RestoreSet
import android.os.RemoteException
import android.os.UserHandle
import android.util.Log
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations.switchMap
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import com.stevesoltys.seedvault.transport.restore.ApkRestore
import com.stevesoltys.seedvault.transport.restore.InstallResult
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 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
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
private val TAG = RestoreViewModel::class.java.simpleName
@ -24,69 +45,127 @@ internal class RestoreViewModel(
app: Application,
settingsManager: SettingsManager,
keyManager: KeyManager,
private val backupManager: IBackupManager
) : RequireProvisioningViewModel(app, settingsManager, keyManager), RestoreSetClickListener {
private val backupManager: IBackupManager,
private val restoreCoordinator: RestoreCoordinator,
private val apkRestore: ApkRestore,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : RequireProvisioningViewModel(app, settingsManager, keyManager), RestorableBackupClickListener {
override val isRestoreOperation = true
private var session: IRestoreSession? = null
private var observer: RestoreObserver? = null
private val monitor = BackupMonitor()
private val mRestoreSets = MutableLiveData<RestoreSetResult>()
internal val restoreSets: LiveData<RestoreSetResult> get() = mRestoreSets
private val mDisplayFragment = MutableLiveEvent<DisplayFragment>()
internal val displayFragment: LiveEvent<DisplayFragment> = mDisplayFragment
private val mChosenRestoreSet = MutableLiveData<RestoreSet>()
internal val chosenRestoreSet: LiveData<RestoreSet> get() = mChosenRestoreSet
private val mRestoreSetResults = MutableLiveData<RestoreSetResult>()
internal val restoreSetResults: LiveData<RestoreSetResult> get() = mRestoreSetResults
private val mChosenRestorableBackup = MutableLiveData<RestorableBackup>()
internal val chosenRestorableBackup: LiveData<RestorableBackup> get() = mChosenRestorableBackup
internal val installResult: LiveData<InstallResult> = switchMap(mChosenRestorableBackup) { backup ->
@Suppress("EXPERIMENTAL_API_USAGE")
getInstallResult(backup)
}
private val mRestoreProgress = MutableLiveData<String>()
internal val restoreProgress: LiveData<String> get() = mRestoreProgress
private val mRestoreFinished = MutableLiveData<Int>()
// Zero on success; a nonzero error code if the restore operation as a whole failed.
internal val restoreFinished: LiveData<Int> get() = mRestoreFinished
private val mRestoreBackupResult = MutableLiveData<RestoreBackupResult>()
internal val restoreBackupResult: LiveData<RestoreBackupResult> get() = mRestoreBackupResult
internal fun loadRestoreSets() {
val session = this.session ?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID)
@Throws(RemoteException::class)
private fun getOrStartSession(): IRestoreSession {
val session = this.session
?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID)
?: throw RemoteException("beginRestoreSessionForUser returned null")
this.session = session
return session
}
if (session == null) {
Log.e(TAG, "beginRestoreSession() returned null session")
mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error))
return
internal fun loadRestoreSets() = viewModelScope.launch {
mRestoreSetResults.value = getAvailableRestoreSets()
}
private suspend fun getAvailableRestoreSets() = suspendCoroutine<RestoreSetResult> { continuation ->
val session = try {
getOrStartSession()
} catch (e: RemoteException) {
Log.e(TAG, "Error starting new session", e)
continuation.resume(RestoreSetResult(app.getString(R.string.restore_set_error)))
return@suspendCoroutine
}
val observer = this.observer ?: RestoreObserver()
this.observer = observer
val observer = RestoreObserver(continuation)
val setResult = session.getAvailableRestoreSets(observer, monitor)
if (setResult != 0) {
Log.e(TAG, "getAvailableRestoreSets() returned non-zero value")
mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error))
return
continuation.resume(RestoreSetResult(app.getString(R.string.restore_set_error)))
return@suspendCoroutine
}
}
override fun onRestoreSetClicked(set: RestoreSet) {
val session = this.session
check(session != null) { "Restore set clicked, but no session available" }
session.restoreAll(set.token, observer, monitor)
override fun onRestorableBackupClicked(restorableBackup: RestorableBackup) {
mChosenRestorableBackup.value = restorableBackup
mDisplayFragment.setEvent(RESTORE_APPS)
mChosenRestoreSet.value = set
// re-installing apps will take some time and the session probably times out
// so better close it cleanly and re-open it later
closeSession()
}
@ExperimentalCoroutinesApi
private fun getInstallResult(restorableBackup: RestorableBackup): LiveData<InstallResult> {
return apkRestore.restore(restorableBackup.token, restorableBackup.packageMetadataMap)
.onStart {
Log.d(TAG, "Start InstallResult Flow")
}.catch { e ->
Log.d(TAG, "Exception in InstallResult Flow", e)
}.onCompletion { e ->
Log.d(TAG, "Completed InstallResult Flow", e)
mDisplayFragment.postEvent(RESTORE_BACKUP)
startRestore(restorableBackup.token)
}
.flowOn(ioDispatcher)
.asLiveData()
}
@WorkerThread
private suspend fun startRestore(token: Long) {
Log.d(TAG, "Starting new restore session to restore backup $token")
// we need to start a new session and retrieve the restore sets before starting the restore
val restoreSetResult = getAvailableRestoreSets()
if (restoreSetResult.hasError()) {
mRestoreBackupResult.postValue(RestoreBackupResult(app.getString(R.string.restore_finished_error)))
return
}
// now we can start the restore of all available packages
val observer = RestoreObserver()
val restoreAllResult = session?.restoreAll(token, observer, monitor) ?: 1
if (restoreAllResult != 0) {
if (session == null) Log.e(TAG, "session was null")
else Log.e(TAG, "restoreAll() returned non-zero value")
mRestoreBackupResult.postValue(RestoreBackupResult(app.getString(R.string.restore_finished_error)))
return
}
}
override fun onCleared() {
super.onCleared()
endSession()
closeSession()
}
private fun endSession() {
private fun closeSession() {
session?.endRestoreSession()
session = null
observer = null
}
@WorkerThread
private inner class RestoreObserver : IRestoreObserver.Stub() {
private inner class RestoreObserver(private val continuation: Continuation<RestoreSetResult>? = null) : IRestoreObserver.Stub() {
/**
* Supply a list of the restore datasets available from the current transport.
@ -98,11 +177,29 @@ internal class RestoreViewModel(
* the current device. If no applicable datasets exist, restoreSets will be null.
*/
override fun restoreSetsAvailable(restoreSets: Array<out RestoreSet>?) {
if (restoreSets == null || restoreSets.isEmpty()) {
mRestoreSets.postValue(RestoreSetResult(app.getString(R.string.restore_set_empty_result)))
check (continuation != null) { "Getting restore sets without continuation" }
val result = if (restoreSets == null || restoreSets.isEmpty()) {
RestoreSetResult(app.getString(R.string.restore_set_empty_result))
} else {
mRestoreSets.postValue(RestoreSetResult(restoreSets))
val backupMetadata = restoreCoordinator.getAndClearBackupMetadata()
if (backupMetadata == null) {
Log.e(TAG, "RestoreCoordinator#getAndClearBackupMetadata() returned null")
RestoreSetResult(app.getString(R.string.restore_set_error))
} else {
val restorableBackups = restoreSets.mapNotNull { set ->
val metadata = backupMetadata[set.token]
if (metadata == null) {
Log.e(TAG, "RestoreCoordinator#getAndClearBackupMetadata() has no metadata for token ${set.token}.")
null
} else {
RestorableBackup(set, metadata)
}
}
RestoreSetResult(restorableBackups)
}
}
continuation.resume(result)
}
/**
@ -135,8 +232,12 @@ internal class RestoreViewModel(
* as a whole failed.
*/
override fun restoreFinished(result: Int) {
mRestoreFinished.postValue(result)
endSession()
val restoreResult = RestoreBackupResult(
if (result == 0) null
else app.getString(R.string.restore_finished_error)
)
mRestoreBackupResult.postValue(restoreResult)
closeSession()
}
}
@ -144,12 +245,18 @@ internal class RestoreViewModel(
}
internal class RestoreSetResult(
internal val sets: Array<out RestoreSet>,
internal val restorableBackups: List<RestorableBackup>,
internal val errorMsg: String?) {
internal constructor(sets: Array<out RestoreSet>) : this(sets, null)
internal constructor(restorableBackups: List<RestorableBackup>) : this(restorableBackups, null)
internal constructor(errorMsg: String) : this(emptyArray(), errorMsg)
internal constructor(errorMsg: String) : this(emptyList(), errorMsg)
internal fun hasError(): Boolean = errorMsg != null
}
internal class RestoreBackupResult(val errorMsg: String? = null) {
internal fun hasError(): Boolean = errorMsg != null
}
internal enum class DisplayFragment { RESTORE_APPS, RESTORE_BACKUP }

View file

@ -0,0 +1,91 @@
package com.stevesoltys.seedvault.transport.restore
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.*
import android.content.Intent.FLAG_RECEIVER_FOREGROUND
import android.content.pm.PackageInstaller
import android.content.pm.PackageInstaller.*
import android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL
import android.content.pm.PackageManager
import android.util.Log
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.FAILED
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.SUCCEEDED
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import java.io.File
import java.io.IOException
private val TAG: String = ApkInstaller::class.java.simpleName
private const val BROADCAST_ACTION = "com.android.packageinstaller.ACTION_INSTALL_COMMIT"
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(cachedApk: File, packageName: String, installerPackageName: String?, installResult: MutableInstallResult) = callbackFlow {
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.registerReceiver(broadcastReceiver, IntentFilter(BROADCAST_ACTION))
install(cachedApk, installerPackageName)
awaitClose { context.unregisterReceiver(broadcastReceiver) }
}
private fun install(cachedApk: File, installerPackageName: String?) {
val sessionParams = SessionParams(MODE_FULL_INSTALL).apply {
setInstallerPackageName(installerPackageName)
}
// 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 ->
cachedApk.inputStream().use { inputStream ->
s.openWrite("PackageInstaller", 0, sizeBytes).use { out ->
inputStream.copyTo(out)
s.fsync(out)
}
}
s.commit(getIntentSender())
}
}
private fun getIntentSender(): IntentSender {
val broadcastIntent = Intent(BROADCAST_ACTION).apply {
flags = FLAG_RECEIVER_FOREGROUND
setPackage(context.packageName)
}
val pendingIntent = PendingIntent.getBroadcast(context, 0, broadcastIntent, FLAG_UPDATE_CURRENT)
return pendingIntent.intentSender
}
private fun onBroadcastReceived(i: Intent, expectedPackageName: String, cachedApk: File, installResult: MutableInstallResult): InstallResult {
val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!!
val success = i.getIntExtra(EXTRA_STATUS, -1) == STATUS_SUCCESS
val statusMsg = i.getStringExtra(EXTRA_STATUS_MESSAGE)!!
check(packageName == expectedPackageName) { "Expected $expectedPackageName, but got $packageName." }
Log.d(TAG, "Received result for $packageName: success=$success $statusMsg")
// delete cached APK file
cachedApk.delete()
// update status and offer result
val status = if (success) SUCCEEDED else FAILED
return installResult.update(packageName) { it.copy(status = status) }
}
}

View file

@ -0,0 +1,167 @@
package com.stevesoltys.seedvault.transport.restore
import android.content.Context
import android.content.pm.PackageManager.GET_SIGNATURES
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.graphics.drawable.Drawable
import android.util.Log
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.transport.backup.getSignatures
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import java.io.File
import java.io.IOException
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
private val TAG = ApkRestore::class.java.simpleName
internal class ApkRestore(
private val context: Context,
private val restorePlugin: RestorePlugin,
private val apkInstaller: ApkInstaller = ApkInstaller(context)) {
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() }
val total = packages.size
var progress = 0
// queue all packages and emit LiveData
val installResult = MutableInstallResult(total)
packages.forEach { (packageName, _) ->
progress++
installResult[packageName] = ApkRestoreResult(progress, total, QUEUED)
}
emit(installResult)
// restore 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)
}
} catch (e: IOException) {
Log.e(TAG, "Error re-installing APK for $packageName.", e)
emit(fail(installResult, packageName))
} catch (e: SecurityException) {
Log.e(TAG, "Security error re-installing APK for $packageName.", e)
emit(fail(installResult, packageName))
} catch (e: TimeoutCancellationException) {
Log.e(TAG, "Timeout while re-installing APK for $packageName.", e)
emit(fail(installResult, packageName))
}
}
}
@ExperimentalCoroutinesApi
@Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
@Throws(IOException::class, SecurityException::class)
private fun restore(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
val messageDigest = MessageDigest.getInstance("SHA-256")
restorePlugin.getApkInputStream(token, packageName).use { inputStream ->
cachedApk.outputStream().use { outputStream ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = inputStream.read(buffer)
while (bytes >= 0) {
outputStream.write(buffer, 0, bytes)
messageDigest.update(buffer, 0, bytes)
bytes = inputStream.read(buffer)
}
}
}
// check APK's SHA-256 hash
val sha256 = messageDigest.digest().encodeBase64()
if (metadata.sha256 != sha256) {
throw SecurityException("Package $packageName has sha256 '$sha256', but '${metadata.sha256}' expected.")
}
// parse APK (GET_SIGNATURES is needed even though deprecated)
@Suppress("DEPRECATION") val flags = GET_SIGNING_CERTIFICATES or GET_SIGNATURES
val packageInfo = pm.getPackageArchiveInfo(cachedApk.absolutePath, flags)
?: throw IOException("getPackageArchiveInfo returned null")
// check APK package name
if (packageName != packageInfo.packageName) {
throw SecurityException("Package $packageName expected, but ${packageInfo.packageName} found.")
}
// check APK version code
if (metadata.version != packageInfo.longVersionCode) {
Log.w(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?
}
// check signatures
if (metadata.signatures != packageInfo.signingInfo.getSignatures()) {
Log.w(TAG, "Package $packageName expects different signatures.")
// TODO should we let this one pass, the sha256 hash already verifies the APK?
}
// get app icon and label (name)
val appInfo = packageInfo.applicationInfo.apply {
// set APK paths before, so package manager can find it for icon extraction
sourceDir = cachedApk.absolutePath
publicSourceDir = cachedApk.absolutePath
}
val icon = appInfo.loadIcon(pm)
val name = pm.getApplicationLabel(appInfo) ?: packageName
installResult.update(packageName) { it.copy(status = IN_PROGRESS, name = name, icon = icon) }
emit(installResult)
// install APK and emit updates from it
apkInstaller.install(cachedApk, packageName, metadata.installer, installResult).collect { result ->
emit(result)
}
}
private fun fail(installResult: MutableInstallResult, packageName: String): InstallResult {
return installResult.update(packageName) { it.copy(status = FAILED) }
}
}
internal typealias InstallResult = Map<String, ApkRestoreResult>
internal fun InstallResult.getInProgress(): ApkRestoreResult? {
val filtered = filterValues { result -> result.status == IN_PROGRESS }
if (filtered.isEmpty()) return null
check(filtered.size == 1) { "More than one package in progress: ${filtered.keys}" }
return filtered.values.first()
}
internal class MutableInstallResult(initialCapacity: Int) : ConcurrentHashMap<String, ApkRestoreResult>(initialCapacity) {
fun update(packageName: String, updateFun: (ApkRestoreResult) -> ApkRestoreResult): MutableInstallResult {
val result = get(packageName)
check(result != null) { "ApkRestoreResult for $packageName does not exist." }
set(packageName, updateFun(result))
return this
}
}
internal data class ApkRestoreResult(
val progress: Int,
val total: Int,
val status: ApkRestoreStatus,
val name: CharSequence? = null,
val icon: Drawable? = null
)
internal enum class ApkRestoreStatus {
QUEUED, IN_PROGRESS, SUCCEEDED, FAILED
}

View file

@ -2,13 +2,16 @@ package com.stevesoltys.seedvault.transport.restore
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.IBackupManager
import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.*
import android.app.backup.RestoreSet
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.collection.LongSparseArray
import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.metadata.BackupMetadata
import com.stevesoltys.seedvault.metadata.DecryptionFailedException
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.MetadataReader
@ -29,6 +32,7 @@ internal class RestoreCoordinator(
private val metadataReader: MetadataReader) {
private var state: RestoreCoordinatorState? = null
private var backupMetadata: LongSparseArray<BackupMetadata>? = null
/**
* Get the set of all backups currently available over this transport.
@ -39,6 +43,7 @@ internal class RestoreCoordinator(
fun getAvailableRestoreSets(): Array<RestoreSet>? {
val availableBackups = plugin.getAvailableBackups() ?: return null
val restoreSets = ArrayList<RestoreSet>()
val metadataMap = LongSparseArray<BackupMetadata>()
for (encryptedMetadata in availableBackups) {
if (encryptedMetadata.error) continue
check(encryptedMetadata.inputStream != null) {
@ -46,6 +51,7 @@ internal class RestoreCoordinator(
}
try {
val metadata = metadataReader.readMetadata(encryptedMetadata.inputStream, encryptedMetadata.token)
metadataMap.put(encryptedMetadata.token, metadata)
val set = RestoreSet(metadata.deviceName, metadata.deviceName, metadata.token)
restoreSets.add(set)
} catch (e: IOException) {
@ -65,6 +71,7 @@ internal class RestoreCoordinator(
}
}
Log.i(TAG, "Got available restore sets: $restoreSets")
this.backupMetadata = metadataMap
return restoreSets.toTypedArray()
}
@ -199,4 +206,16 @@ internal class RestoreCoordinator(
if (full.hasState()) full.finishRestore()
}
/**
* Call this after calling [IBackupManager.getAvailableRestoreTokenForUser]
* to retrieve additional [BackupMetadata] that is not available in [RestoreSet].
*
* It will also clear the saved metadata, so that subsequent calls will return null.
*/
fun getAndClearBackupMetadata(): LongSparseArray<BackupMetadata>? {
val result = backupMetadata
backupMetadata = null
return result
}
}

View file

@ -1,9 +1,11 @@
package com.stevesoltys.seedvault.transport.restore
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val restoreModule = module {
single { OutputFactory() }
factory { ApkRestore(androidContext(), get()) }
single { KVRestore(get<RestorePlugin>().kvRestorePlugin, get(), get(), get()) }
single { FullRestore(get<RestorePlugin>().fullRestorePlugin, get(), get(), get()) }
single { RestoreCoordinator(get(), get(), get(), get(), get()) }

View file

@ -3,6 +3,8 @@ package com.stevesoltys.seedvault.transport.restore
import android.net.Uri
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.metadata.EncryptedBackupMetadata
import java.io.IOException
import java.io.InputStream
interface RestorePlugin {
@ -27,4 +29,10 @@ interface RestorePlugin {
@WorkerThread
fun hasBackup(uri: Uri): Boolean
/**
* Returns an [InputStream] for the given token, for reading an APK that is to be restored.
*/
@Throws(IOException::class)
fun getApkInputStream(token: Long, packageName: String): InputStream
}

View file

@ -4,8 +4,8 @@ import android.app.Application
import android.net.Uri
import android.util.Log
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
private val TAG = RestoreStorageViewModel::class.java.simpleName

View file

@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="0dp"
android:layout_height="4dp"
android:indeterminate="false"
android:padding="0dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:max="23"
tools:progress="5" />
<ImageView
android:id="@+id/imageView"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_margin="16dp"
android:tint="?android:colorAccent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/progressBar"
app:srcCompat="@drawable/ic_cloud_download"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/titleView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/restore_installing_packages"
android:textColor="?android:textColorSecondary"
android:textSize="24sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView" />
<TextView
android:id="@+id/backupNameView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:textColor="?android:textColorTertiary"
android:textSize="18sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView"
tools:text="Pixel 2 XL" />
<ImageView
android:id="@+id/currentPackageImageView"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginTop="16dp"
android:scaleType="fitCenter"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/backupNameView"
tools:ignore="ContentDescription"
tools:srcCompat="@tools:sample/avatars" />
<TextView
android:id="@+id/currentPackageView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:gravity="center_horizontal"
android:textColor="?android:textColorSecondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/currentPackageImageView"
tools:text="@string/restore_current_package" />
<ProgressBar
android:id="@+id/roundProgressBar"
style="?android:attr/progressBarStyleLarge"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/currentPackageView" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -66,22 +66,6 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/currentPackageView" />
<TextView
android:id="@+id/warningView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="32dp"
android:layout_marginEnd="16dp"
android:textSize="18sp"
android:text="@string/restore_finished_warning_only_installed"
android:textColor="@android:color/holo_red_dark"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/progressBar"
tools:visibility="visible" />
<Button
android:id="@+id/button"
style="@style/Widget.AppCompat.Button.Colored"
@ -93,7 +77,7 @@
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/warningView"
app:layout_constraintTop_toBottomOf="@+id/progressBar"
app:layout_constraintVertical_bias="1.0"
tools:visibility="visible" />

View file

@ -2,4 +2,5 @@
<resources>
<color name="accent">#99cc00</color>
<color name="divider">#8A000000</color>
<color name="red">#D32F2F</color>
</resources>

View file

@ -14,7 +14,7 @@
<string name="settings_backup_location_none">None</string>
<string name="settings_backup_location_internal">Internal Storage</string>
<string name="settings_backup_last_backup_never">Never</string>
<string name="settings_backup_location_summary">%s · Last Backup %s</string>
<string name="settings_backup_location_summary">%1$s · Last Backup %2$s</string>
<string name="settings_info">All backups are encrypted on your phone. To restore from backup you will need your 12-word recovery code.</string>
<string name="settings_auto_restore_title">Automatic restore</string>
<string name="settings_auto_restore_summary">When reinstalling an app, restore backed up settings and data</string>
@ -76,17 +76,17 @@
<!-- Restore -->
<string name="restore_title">Restore from Backup</string>
<string name="restore_choose_restore_set">Choose a backup to restore</string>
<string name="restore_restore_set_times">Last Backup %1$s · First %2$s.</string>
<string name="restore_back">Don\'t restore</string>
<string name="restore_invalid_location_title">No backups found</string>
<string name="restore_invalid_location_message">We could not find any backups at this location.\n\nPlease choose another location that contains a %s folder.</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_installing_packages">Re-installing Apps</string>
<string name="restore_restoring">Restoring Backup</string>
<string name="restore_current_package">Restoring %s…</string>
<string name="restore_finished_success">Restore complete.</string>
<string name="restore_finished_error">An error occurred while restoring the backup.</string>
<string name="restore_finished_warning_only_installed">Note that we could only restore data for apps that are already installed.\n\nWhen you install more apps, we will try to restore their data and settings from this backup. So please do not delete it as long as it might still be needed.%s</string>
<string name="restore_finished_warning_ejectable">\n\nPlease also ensure that the storage medium is plugged in when re-installing your apps.</string>
<string name="restore_finished_button">Finish</string>
<string name="storage_internal_warning_title">Warning</string>
<string name="storage_internal_warning_message">You have chosen internal storage for your backup. This will not be available when your phone is lost or broken.</string>

View file

@ -0,0 +1,181 @@
package com.stevesoltys.seedvault.transport.restore
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.*
import io.mockk.every
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.fail
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayInputStream
import java.io.File
import java.nio.file.Path
import kotlin.random.Random
@ExperimentalCoroutinesApi
internal class ApkRestoreTest : RestoreTest() {
private val pm: PackageManager = mockk()
private val strictContext: Context = mockk<Context>().apply {
every { packageManager } returns pm
}
private val restorePlugin: RestorePlugin = mockk()
private val apkInstaller: ApkInstaller = mockk()
private val apkRestore: ApkRestore = ApkRestore(strictContext, restorePlugin, apkInstaller)
private val icon: Drawable = mockk()
private val packageName = packageInfo.packageName
private val packageMetadata = PackageMetadata(
time = Random.nextLong(),
version = packageInfo.longVersionCode - 1,
installer = getRandomString(),
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI",
signatures = listOf("AwIB")
)
private val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata)
private val apkBytes = byteArrayOf(0x04, 0x05, 0x06)
private val apkInputStream = ByteArrayInputStream(apkBytes)
private val appName = getRandomString()
private val installerName = packageMetadata.installer
init {
// as we don't do strict signature checking, we can use a relaxed mock
packageInfo.signingInfo = mockk(relaxed = true)
}
@Test
fun `signature mismatch causes FAILED status`(@TempDir tmpDir: Path) = runBlocking {
// change SHA256 signature to random
val packageMetadata = packageMetadata.copy(sha256 = getRandomString())
val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata)
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) {
0 -> {
val result = value[packageName] ?: fail()
assertEquals(QUEUED, result.status)
assertEquals(1, result.progress)
assertEquals(1, result.total)
}
1 -> {
val result = value[packageName] ?: fail()
assertEquals(FAILED, result.status)
}
else -> fail()
}
}
}
@Test
fun `package name mismatch causes FAILED status`(@TempDir tmpDir: Path) = runBlocking {
// change package name to random string
packageInfo.packageName = getRandomString()
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) {
0 -> {
val result = value[packageName] ?: fail()
assertEquals(QUEUED, result.status)
assertEquals(1, result.progress)
assertEquals(1, result.total)
}
1 -> {
val result = value[packageName] ?: fail()
assertEquals(FAILED, result.status)
}
else -> fail()
}
}
}
@Test
fun `test apkInstaller throws exceptions`(@TempDir tmpDir: Path) = runBlocking {
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every { pm.loadItemIcon(packageInfo.applicationInfo, packageInfo.applicationInfo) } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
every { apkInstaller.install(any(), packageName, installerName, any()) } throws SecurityException()
apkRestore.restore(token, packageMetadataMap).collectIndexed { index, value ->
when (index) {
0 -> {
val result = value[packageName] ?: fail()
assertEquals(QUEUED, result.status)
assertEquals(1, result.progress)
assertEquals(1, result.total)
}
1 -> {
val result = value[packageName] ?: fail()
assertEquals(IN_PROGRESS, result.status)
assertEquals(appName, result.name)
assertEquals(icon, result.icon)
}
2 -> {
val result = value[packageName] ?: fail()
assertEquals(FAILED, result.status)
}
else -> fail()
}
}
}
@Test
fun `test successful run`(@TempDir tmpDir: Path) = runBlocking {
val installResult = MutableInstallResult(1).apply {
put(packageName, ApkRestoreResult(progress = 1, total = 1, status = SUCCEEDED))
}
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { restorePlugin.getApkInputStream(token, packageName) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every { pm.loadItemIcon(packageInfo.applicationInfo, packageInfo.applicationInfo) } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName
every { apkInstaller.install(any(), packageName, installerName, any()) } returns flowOf(installResult)
var i = 0
apkRestore.restore(token, packageMetadataMap).collect { value ->
when (i) {
0 -> {
val result = value[packageName] ?: fail()
assertEquals(QUEUED, result.status)
assertEquals(1, result.progress)
assertEquals(1, result.total)
}
1 -> {
val result = value[packageName] ?: fail()
assertEquals(IN_PROGRESS, result.status)
assertEquals(appName, result.name)
assertEquals(icon, result.icon)
}
2 -> {
val result = value[packageName] ?: fail()
assertEquals(SUCCEEDED, result.status)
}
else -> fail()
}
i++
}
}
}

View file

@ -3,6 +3,7 @@
<privapp-permissions package="com.stevesoltys.seedvault">
<permission name="android.permission.BACKUP"/>
<permission name="android.permission.MANAGE_USB"/>
<permission name="android.permission.INSTALL_PACKAGES"/>
<permission name="android.permission.WRITE_SECURE_SETTINGS"/>
</privapp-permissions>
</permissions>