Re-install backed-up APKs before restoring from backup
diff --git a/app/build.gradle b/app/build.gradle
index 82fb517..1ed8157 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -119,6 +119,8 @@
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 a792b72..dc86fdf 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" />
+ <!-- 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"
diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt
index 44636ff..3cebd5d 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/App.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt
@@ -40,7 +40,7 @@
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() {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt
index 7270e85..d9375c2 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderRestorePlugin.kt
@@ -9,7 +9,9 @@
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 @@
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)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/InstallProgressFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/InstallProgressFragment.kt
new file mode 100644
index 0000000..42e6e4f
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/InstallProgressFragment.kt
@@ -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
+ }
+
+}
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 0000000..34cab84
--- /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 5f4a87b..ff91160 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 @@
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 @@
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 faab9c0..cd877a7 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.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 @@
// 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 @@
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 35d9662..f31ab1e 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.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 @@
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)
}
}
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 fe4852d..bcc4a2d 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 @@
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 @@
}
}
- 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 1c3524f..47a49ab 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.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 @@
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 @@
* 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 @@
* 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 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 }
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 0000000..b32e13b
--- /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 0000000..01a52f3
--- /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<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
+}
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 d353112..39aad11 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 @@
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 @@
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 @@
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 @@
}
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 @@
}
}
Log.i(TAG, "Got available restore sets: $restoreSets")
+ this.backupMetadata = metadataMap
return restoreSets.toTypedArray()
}
@@ -199,4 +206,16 @@
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
+ }
+
}
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 6dfd5b2..a4d9f4a 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<RestorePlugin>().kvRestorePlugin, get(), get(), get()) }
single { FullRestore(get<RestorePlugin>().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 607469d..750c9b1 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 @@
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 @@
@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 44864b9..1df0966 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.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 0000000..43617d4
--- /dev/null
+++ b/app/src/main/res/layout/fragment_install_progress.xml
@@ -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>
diff --git a/app/src/main/res/layout/fragment_restore_progress.xml b/app/src/main/res/layout/fragment_restore_progress.xml
index 49983f4..1ffbe41 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" />
- <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" />
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 4dec0d1..000de29 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -2,4 +2,5 @@
<resources>
<color name="accent">#99cc00</color>
<color name="divider">#8A000000</color>
+ <color name="red">#D32F2F</color>
</resources>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9e207a7..f546fcd 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -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>
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 0000000..f7fdaae
--- /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<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++
+ }
+ }
+
+}