diff --git a/README.md b/README.md
index ae9ddbd4..304c0044 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/app/build.gradle b/app/build.gradle
index 82fb5174..1ed8157f 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a792b726..dc86fdff 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -24,6 +24,11 @@
android:name="android.permission.WRITE_SECURE_SETTINGS"
tools:ignore="ProtectedPermissions" />
+
+
+
+ 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
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt
new file mode 100644
index 00000000..34cab843
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt
@@ -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
+
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt
index 5f4a87bf..ff911602 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt
@@ -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) {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt
index faab9c0f..cd877a7b 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt
@@ -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)
})
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt
index 35d96628..f31ab1e1 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt
@@ -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) : Adapter() {
+ private val listener: RestorableBackupClickListener,
+ private val items: List) : Adapter() {
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(R.id.titleView)
private val subtitleView = v.findViewById(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)
}
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt
index fe4852dd..bcc4a2d0 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt
@@ -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)
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt
index 1c3524f9..47a49abc 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt
@@ -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()
- internal val restoreSets: LiveData get() = mRestoreSets
+ private val mDisplayFragment = MutableLiveEvent()
+ internal val displayFragment: LiveEvent = mDisplayFragment
- private val mChosenRestoreSet = MutableLiveData()
- internal val chosenRestoreSet: LiveData get() = mChosenRestoreSet
+ private val mRestoreSetResults = MutableLiveData()
+ internal val restoreSetResults: LiveData get() = mRestoreSetResults
+
+ private val mChosenRestorableBackup = MutableLiveData()
+ internal val chosenRestorableBackup: LiveData get() = mChosenRestorableBackup
+
+ internal val installResult: LiveData = switchMap(mChosenRestorableBackup) { backup ->
+ @Suppress("EXPERIMENTAL_API_USAGE")
+ getInstallResult(backup)
+ }
private val mRestoreProgress = MutableLiveData()
internal val restoreProgress: LiveData get() = mRestoreProgress
- private val mRestoreFinished = MutableLiveData()
- // Zero on success; a nonzero error code if the restore operation as a whole failed.
- internal val restoreFinished: LiveData get() = mRestoreFinished
+ private val mRestoreBackupResult = MutableLiveData()
+ internal val restoreBackupResult: LiveData 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 { 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 {
+ 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? = 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?) {
- 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,
+ internal val restorableBackups: List,
internal val errorMsg: String?) {
- internal constructor(sets: Array) : this(sets, null)
+ internal constructor(restorableBackups: List) : 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 }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkInstaller.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkInstaller.kt
new file mode 100644
index 00000000..b32e13b5
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkInstaller.kt
@@ -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) }
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkRestore.kt
new file mode 100644
index 00000000..01a52f35
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkRestore.kt
@@ -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
+
+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(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
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt
index d3531129..39aad112 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt
@@ -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? = null
/**
* Get the set of all backups currently available over this transport.
@@ -39,6 +43,7 @@ internal class RestoreCoordinator(
fun getAvailableRestoreSets(): Array? {
val availableBackups = plugin.getAvailableBackups() ?: return null
val restoreSets = ArrayList()
+ val metadataMap = LongSparseArray()
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? {
+ val result = backupMetadata
+ backupMetadata = null
+ return result
+ }
+
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt
index 6dfd5b27..a4d9f4a3 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt
@@ -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().kvRestorePlugin, get(), get(), get()) }
single { FullRestore(get().fullRestorePlugin, get(), get(), get()) }
single { RestoreCoordinator(get(), get(), get(), get(), get()) }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt
index 607469d8..750c9b11 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt
@@ -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
+
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt
index 44864b92..1df09664 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt
@@ -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
diff --git a/app/src/main/res/layout/fragment_install_progress.xml b/app/src/main/res/layout/fragment_install_progress.xml
new file mode 100644
index 00000000..43617d49
--- /dev/null
+++ b/app/src/main/res/layout/fragment_install_progress.xml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_restore_progress.xml b/app/src/main/res/layout/fragment_restore_progress.xml
index 49983f4d..1ffbe413 100644
--- a/app/src/main/res/layout/fragment_restore_progress.xml
+++ b/app/src/main/res/layout/fragment_restore_progress.xml
@@ -66,22 +66,6 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/currentPackageView" />
-
-
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 4dec0d1c..000de296 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -2,4 +2,5 @@
#99cc00
#8A000000
+ #D32F2F
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9e207a7e..f546fcd3 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -14,7 +14,7 @@
None
Internal Storage
Never
- %s · Last Backup %s
+ %1$s · Last Backup %2$s
All backups are encrypted on your phone. To restore from backup you will need your 12-word recovery code.
Automatic restore
When reinstalling an app, restore backed up settings and data
@@ -76,17 +76,17 @@
Restore from Backup
Choose a backup to restore
+ Last Backup %1$s · First %2$s.
Don\'t restore
No backups found
We could not find any backups at this location.\n\nPlease choose another location that contains a %s folder.
An error occurred while loading the backups.
No suitable backups found at given location.\n\nThis is most likely due to a wrong recovery code or a storage error.
+ Re-installing Apps
Restoring Backup
Restoring %s…
Restore complete.
An error occurred while restoring the backup.
- 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
- \n\nPlease also ensure that the storage medium is plugged in when re-installing your apps.
Finish
Warning
You have chosen internal storage for your backup. This will not be available when your phone is lost or broken.
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/ApkRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/ApkRestoreTest.kt
new file mode 100644
index 00000000..f7fdaaee
--- /dev/null
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/ApkRestoreTest.kt
@@ -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().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++
+ }
+ }
+
+}
diff --git a/permissions_com.stevesoltys.seedvault.xml b/permissions_com.stevesoltys.seedvault.xml
index ef41a134..da36b245 100644
--- a/permissions_com.stevesoltys.seedvault.xml
+++ b/permissions_com.stevesoltys.seedvault.xml
@@ -3,6 +3,7 @@
+
\ No newline at end of file