Re-install backed-up APKs before restoring from backup
This commit is contained in:
parent
569e3db385
commit
7605762631
24 changed files with 845 additions and 97 deletions
|
@ -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.
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -40,7 +40,7 @@ class App : Application() {
|
|||
viewModel { RecoveryCodeViewModel(this@App, get()) }
|
||||
viewModel { BackupStorageViewModel(this@App, get(), get()) }
|
||||
viewModel { RestoreStorageViewModel(this@App, get(), get()) }
|
||||
viewModel { RestoreViewModel(this@App, get(), get(), get()) }
|
||||
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) }
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
|
|
|
@ -9,7 +9,9 @@ import com.stevesoltys.seedvault.metadata.EncryptedBackupMetadata
|
|||
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
|
||||
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
|
||||
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName
|
||||
|
||||
|
@ -84,6 +86,13 @@ internal class DocumentsProviderRestorePlugin(
|
|||
return backupSets
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun getApkInputStream(token: Long, packageName: String): InputStream {
|
||||
val setDir = storage.getSetDir(token) ?: throw IOException()
|
||||
val file = setDir.findFile("$packageName.apk") ?: throw FileNotFoundException()
|
||||
return storage.getInputStream(file)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class BackupSet(val token: Long, val metadataFile: DocumentFile)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package com.stevesoltys.seedvault.restore
|
||||
|
||||
import android.app.backup.RestoreSet
|
||||
import android.text.format.DateUtils.*
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
@ -11,8 +11,8 @@ import com.stevesoltys.seedvault.R
|
|||
import com.stevesoltys.seedvault.restore.RestoreSetAdapter.RestoreSetViewHolder
|
||||
|
||||
internal class RestoreSetAdapter(
|
||||
private val listener: RestoreSetClickListener,
|
||||
private val items: Array<out RestoreSet>) : Adapter<RestoreSetViewHolder>() {
|
||||
private val listener: RestorableBackupClickListener,
|
||||
private val items: List<RestorableBackup>) : Adapter<RestoreSetViewHolder>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RestoreSetViewHolder {
|
||||
val v = LayoutInflater.from(parent.context)
|
||||
|
@ -31,10 +31,18 @@ internal class RestoreSetAdapter(
|
|||
private val titleView = v.findViewById<TextView>(R.id.titleView)
|
||||
private val subtitleView = v.findViewById<TextView>(R.id.subtitleView)
|
||||
|
||||
internal fun bind(item: RestoreSet) {
|
||||
v.setOnClickListener { listener.onRestoreSetClicked(item) }
|
||||
internal fun bind(item: RestorableBackup) {
|
||||
v.setOnClickListener { listener.onRestorableBackupClicked(item) }
|
||||
titleView.text = item.name
|
||||
subtitleView.text = "Android Backup" // TODO change to backup date when available
|
||||
|
||||
val lastBackup = getRelativeTime(item.time)
|
||||
val setup = getRelativeTime(item.token)
|
||||
subtitleView.text = v.context.getString(R.string.restore_restore_set_times, lastBackup, setup)
|
||||
}
|
||||
|
||||
private fun getRelativeTime(time: Long): CharSequence {
|
||||
val now = System.currentTimeMillis()
|
||||
return getRelativeTimeSpanString(time, now, HOUR_IN_MILLIS, FORMAT_ABBREV_RELATIVE)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -5,18 +5,39 @@ import android.app.backup.IBackupManager
|
|||
import android.app.backup.IRestoreObserver
|
||||
import android.app.backup.IRestoreSession
|
||||
import android.app.backup.RestoreSet
|
||||
import android.os.RemoteException
|
||||
import android.os.UserHandle
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Transformations.switchMap
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.stevesoltys.seedvault.BackupMonitor
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
|
||||
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
|
||||
import com.stevesoltys.seedvault.transport.restore.ApkRestore
|
||||
import com.stevesoltys.seedvault.transport.restore.InstallResult
|
||||
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
||||
import com.stevesoltys.seedvault.ui.LiveEvent
|
||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
private val TAG = RestoreViewModel::class.java.simpleName
|
||||
|
||||
|
@ -24,69 +45,127 @@ internal class RestoreViewModel(
|
|||
app: Application,
|
||||
settingsManager: SettingsManager,
|
||||
keyManager: KeyManager,
|
||||
private val backupManager: IBackupManager
|
||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager), RestoreSetClickListener {
|
||||
private val backupManager: IBackupManager,
|
||||
private val restoreCoordinator: RestoreCoordinator,
|
||||
private val apkRestore: ApkRestore,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager), RestorableBackupClickListener {
|
||||
|
||||
override val isRestoreOperation = true
|
||||
|
||||
private var session: IRestoreSession? = null
|
||||
private var observer: RestoreObserver? = null
|
||||
private val monitor = BackupMonitor()
|
||||
|
||||
private val mRestoreSets = MutableLiveData<RestoreSetResult>()
|
||||
internal val restoreSets: LiveData<RestoreSetResult> get() = mRestoreSets
|
||||
private val mDisplayFragment = MutableLiveEvent<DisplayFragment>()
|
||||
internal val displayFragment: LiveEvent<DisplayFragment> = mDisplayFragment
|
||||
|
||||
private val mChosenRestoreSet = MutableLiveData<RestoreSet>()
|
||||
internal val chosenRestoreSet: LiveData<RestoreSet> get() = mChosenRestoreSet
|
||||
private val mRestoreSetResults = MutableLiveData<RestoreSetResult>()
|
||||
internal val restoreSetResults: LiveData<RestoreSetResult> get() = mRestoreSetResults
|
||||
|
||||
private val mChosenRestorableBackup = MutableLiveData<RestorableBackup>()
|
||||
internal val chosenRestorableBackup: LiveData<RestorableBackup> get() = mChosenRestorableBackup
|
||||
|
||||
internal val installResult: LiveData<InstallResult> = switchMap(mChosenRestorableBackup) { backup ->
|
||||
@Suppress("EXPERIMENTAL_API_USAGE")
|
||||
getInstallResult(backup)
|
||||
}
|
||||
|
||||
private val mRestoreProgress = MutableLiveData<String>()
|
||||
internal val restoreProgress: LiveData<String> get() = mRestoreProgress
|
||||
|
||||
private val mRestoreFinished = MutableLiveData<Int>()
|
||||
// Zero on success; a nonzero error code if the restore operation as a whole failed.
|
||||
internal val restoreFinished: LiveData<Int> get() = mRestoreFinished
|
||||
private val mRestoreBackupResult = MutableLiveData<RestoreBackupResult>()
|
||||
internal val restoreBackupResult: LiveData<RestoreBackupResult> get() = mRestoreBackupResult
|
||||
|
||||
internal fun loadRestoreSets() {
|
||||
val session = this.session ?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID)
|
||||
@Throws(RemoteException::class)
|
||||
private fun getOrStartSession(): IRestoreSession {
|
||||
val session = this.session
|
||||
?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID)
|
||||
?: throw RemoteException("beginRestoreSessionForUser returned null")
|
||||
this.session = session
|
||||
return session
|
||||
}
|
||||
|
||||
if (session == null) {
|
||||
Log.e(TAG, "beginRestoreSession() returned null session")
|
||||
mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error))
|
||||
return
|
||||
internal fun loadRestoreSets() = viewModelScope.launch {
|
||||
mRestoreSetResults.value = getAvailableRestoreSets()
|
||||
}
|
||||
|
||||
private suspend fun getAvailableRestoreSets() = suspendCoroutine<RestoreSetResult> { continuation ->
|
||||
val session = try {
|
||||
getOrStartSession()
|
||||
} catch (e: RemoteException) {
|
||||
Log.e(TAG, "Error starting new session", e)
|
||||
continuation.resume(RestoreSetResult(app.getString(R.string.restore_set_error)))
|
||||
return@suspendCoroutine
|
||||
}
|
||||
val observer = this.observer ?: RestoreObserver()
|
||||
this.observer = observer
|
||||
|
||||
val observer = RestoreObserver(continuation)
|
||||
val setResult = session.getAvailableRestoreSets(observer, monitor)
|
||||
if (setResult != 0) {
|
||||
Log.e(TAG, "getAvailableRestoreSets() returned non-zero value")
|
||||
mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error))
|
||||
return
|
||||
continuation.resume(RestoreSetResult(app.getString(R.string.restore_set_error)))
|
||||
return@suspendCoroutine
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRestoreSetClicked(set: RestoreSet) {
|
||||
val session = this.session
|
||||
check(session != null) { "Restore set clicked, but no session available" }
|
||||
session.restoreAll(set.token, observer, monitor)
|
||||
override fun onRestorableBackupClicked(restorableBackup: RestorableBackup) {
|
||||
mChosenRestorableBackup.value = restorableBackup
|
||||
mDisplayFragment.setEvent(RESTORE_APPS)
|
||||
|
||||
mChosenRestoreSet.value = set
|
||||
// re-installing apps will take some time and the session probably times out
|
||||
// so better close it cleanly and re-open it later
|
||||
closeSession()
|
||||
}
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
private fun getInstallResult(restorableBackup: RestorableBackup): LiveData<InstallResult> {
|
||||
return apkRestore.restore(restorableBackup.token, restorableBackup.packageMetadataMap)
|
||||
.onStart {
|
||||
Log.d(TAG, "Start InstallResult Flow")
|
||||
}.catch { e ->
|
||||
Log.d(TAG, "Exception in InstallResult Flow", e)
|
||||
}.onCompletion { e ->
|
||||
Log.d(TAG, "Completed InstallResult Flow", e)
|
||||
mDisplayFragment.postEvent(RESTORE_BACKUP)
|
||||
startRestore(restorableBackup.token)
|
||||
}
|
||||
.flowOn(ioDispatcher)
|
||||
.asLiveData()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private suspend fun startRestore(token: Long) {
|
||||
Log.d(TAG, "Starting new restore session to restore backup $token")
|
||||
|
||||
// we need to start a new session and retrieve the restore sets before starting the restore
|
||||
val restoreSetResult = getAvailableRestoreSets()
|
||||
if (restoreSetResult.hasError()) {
|
||||
mRestoreBackupResult.postValue(RestoreBackupResult(app.getString(R.string.restore_finished_error)))
|
||||
return
|
||||
}
|
||||
|
||||
// now we can start the restore of all available packages
|
||||
val observer = RestoreObserver()
|
||||
val restoreAllResult = session?.restoreAll(token, observer, monitor) ?: 1
|
||||
if (restoreAllResult != 0) {
|
||||
if (session == null) Log.e(TAG, "session was null")
|
||||
else Log.e(TAG, "restoreAll() returned non-zero value")
|
||||
mRestoreBackupResult.postValue(RestoreBackupResult(app.getString(R.string.restore_finished_error)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
endSession()
|
||||
closeSession()
|
||||
}
|
||||
|
||||
private fun endSession() {
|
||||
private fun closeSession() {
|
||||
session?.endRestoreSession()
|
||||
session = null
|
||||
observer = null
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private inner class RestoreObserver : IRestoreObserver.Stub() {
|
||||
private inner class RestoreObserver(private val continuation: Continuation<RestoreSetResult>? = null) : IRestoreObserver.Stub() {
|
||||
|
||||
/**
|
||||
* Supply a list of the restore datasets available from the current transport.
|
||||
|
@ -98,11 +177,29 @@ internal class RestoreViewModel(
|
|||
* the current device. If no applicable datasets exist, restoreSets will be null.
|
||||
*/
|
||||
override fun restoreSetsAvailable(restoreSets: Array<out RestoreSet>?) {
|
||||
if (restoreSets == null || restoreSets.isEmpty()) {
|
||||
mRestoreSets.postValue(RestoreSetResult(app.getString(R.string.restore_set_empty_result)))
|
||||
check (continuation != null) { "Getting restore sets without continuation" }
|
||||
|
||||
val result = if (restoreSets == null || restoreSets.isEmpty()) {
|
||||
RestoreSetResult(app.getString(R.string.restore_set_empty_result))
|
||||
} else {
|
||||
mRestoreSets.postValue(RestoreSetResult(restoreSets))
|
||||
val backupMetadata = restoreCoordinator.getAndClearBackupMetadata()
|
||||
if (backupMetadata == null) {
|
||||
Log.e(TAG, "RestoreCoordinator#getAndClearBackupMetadata() returned null")
|
||||
RestoreSetResult(app.getString(R.string.restore_set_error))
|
||||
} else {
|
||||
val restorableBackups = restoreSets.mapNotNull { set ->
|
||||
val metadata = backupMetadata[set.token]
|
||||
if (metadata == null) {
|
||||
Log.e(TAG, "RestoreCoordinator#getAndClearBackupMetadata() has no metadata for token ${set.token}.")
|
||||
null
|
||||
} else {
|
||||
RestorableBackup(set, metadata)
|
||||
}
|
||||
}
|
||||
RestoreSetResult(restorableBackups)
|
||||
}
|
||||
}
|
||||
continuation.resume(result)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -135,8 +232,12 @@ internal class RestoreViewModel(
|
|||
* as a whole failed.
|
||||
*/
|
||||
override fun restoreFinished(result: Int) {
|
||||
mRestoreFinished.postValue(result)
|
||||
endSession()
|
||||
val restoreResult = RestoreBackupResult(
|
||||
if (result == 0) null
|
||||
else app.getString(R.string.restore_finished_error)
|
||||
)
|
||||
mRestoreBackupResult.postValue(restoreResult)
|
||||
closeSession()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -144,12 +245,18 @@ internal class RestoreViewModel(
|
|||
}
|
||||
|
||||
internal class RestoreSetResult(
|
||||
internal val sets: Array<out RestoreSet>,
|
||||
internal val restorableBackups: List<RestorableBackup>,
|
||||
internal val errorMsg: String?) {
|
||||
|
||||
internal constructor(sets: Array<out RestoreSet>) : this(sets, null)
|
||||
internal constructor(restorableBackups: List<RestorableBackup>) : this(restorableBackups, null)
|
||||
|
||||
internal constructor(errorMsg: String) : this(emptyArray(), errorMsg)
|
||||
internal constructor(errorMsg: String) : this(emptyList(), errorMsg)
|
||||
|
||||
internal fun hasError(): Boolean = errorMsg != null
|
||||
}
|
||||
|
||||
internal class RestoreBackupResult(val errorMsg: String? = null) {
|
||||
internal fun hasError(): Boolean = errorMsg != null
|
||||
}
|
||||
|
||||
internal enum class DisplayFragment { RESTORE_APPS, RESTORE_BACKUP }
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -2,13 +2,16 @@ package com.stevesoltys.seedvault.transport.restore
|
|||
|
||||
import android.app.backup.BackupTransport.TRANSPORT_ERROR
|
||||
import android.app.backup.BackupTransport.TRANSPORT_OK
|
||||
import android.app.backup.IBackupManager
|
||||
import android.app.backup.RestoreDescription
|
||||
import android.app.backup.RestoreDescription.*
|
||||
import android.app.backup.RestoreSet
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import androidx.collection.LongSparseArray
|
||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||
import com.stevesoltys.seedvault.metadata.BackupMetadata
|
||||
import com.stevesoltys.seedvault.metadata.DecryptionFailedException
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.metadata.MetadataReader
|
||||
|
@ -29,6 +32,7 @@ internal class RestoreCoordinator(
|
|||
private val metadataReader: MetadataReader) {
|
||||
|
||||
private var state: RestoreCoordinatorState? = null
|
||||
private var backupMetadata: LongSparseArray<BackupMetadata>? = null
|
||||
|
||||
/**
|
||||
* Get the set of all backups currently available over this transport.
|
||||
|
@ -39,6 +43,7 @@ internal class RestoreCoordinator(
|
|||
fun getAvailableRestoreSets(): Array<RestoreSet>? {
|
||||
val availableBackups = plugin.getAvailableBackups() ?: return null
|
||||
val restoreSets = ArrayList<RestoreSet>()
|
||||
val metadataMap = LongSparseArray<BackupMetadata>()
|
||||
for (encryptedMetadata in availableBackups) {
|
||||
if (encryptedMetadata.error) continue
|
||||
check(encryptedMetadata.inputStream != null) {
|
||||
|
@ -46,6 +51,7 @@ internal class RestoreCoordinator(
|
|||
}
|
||||
try {
|
||||
val metadata = metadataReader.readMetadata(encryptedMetadata.inputStream, encryptedMetadata.token)
|
||||
metadataMap.put(encryptedMetadata.token, metadata)
|
||||
val set = RestoreSet(metadata.deviceName, metadata.deviceName, metadata.token)
|
||||
restoreSets.add(set)
|
||||
} catch (e: IOException) {
|
||||
|
@ -65,6 +71,7 @@ internal class RestoreCoordinator(
|
|||
}
|
||||
}
|
||||
Log.i(TAG, "Got available restore sets: $restoreSets")
|
||||
this.backupMetadata = metadataMap
|
||||
return restoreSets.toTypedArray()
|
||||
}
|
||||
|
||||
|
@ -199,4 +206,16 @@ internal class RestoreCoordinator(
|
|||
if (full.hasState()) full.finishRestore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this after calling [IBackupManager.getAvailableRestoreTokenForUser]
|
||||
* to retrieve additional [BackupMetadata] that is not available in [RestoreSet].
|
||||
*
|
||||
* It will also clear the saved metadata, so that subsequent calls will return null.
|
||||
*/
|
||||
fun getAndClearBackupMetadata(): LongSparseArray<BackupMetadata>? {
|
||||
val result = backupMetadata
|
||||
backupMetadata = null
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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()) }
|
||||
|
|
|
@ -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
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
93
app/src/main/res/layout/fragment_install_progress.xml
Normal file
93
app/src/main/res/layout/fragment_install_progress.xml
Normal file
|
@ -0,0 +1,93 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="4dp"
|
||||
android:indeterminate="false"
|
||||
android:padding="0dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:max="23"
|
||||
tools:progress="5" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_margin="16dp"
|
||||
android:tint="?android:colorAccent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/progressBar"
|
||||
app:srcCompat="@drawable/ic_cloud_download"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
android:text="@string/restore_installing_packages"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
android:textSize="24sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/imageView" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/backupNameView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
android:textColor="?android:textColorTertiary"
|
||||
android:textSize="18sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/titleView"
|
||||
tools:text="Pixel 2 XL" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/currentPackageImageView"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:scaleType="fitCenter"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/backupNameView"
|
||||
tools:ignore="ContentDescription"
|
||||
tools:srcCompat="@tools:sample/avatars" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/currentPackageView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/currentPackageImageView"
|
||||
tools:text="@string/restore_current_package" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/roundProgressBar"
|
||||
style="?android:attr/progressBarStyleLarge"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/currentPackageView" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -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" />
|
||||
|
||||
|
|
|
@ -2,4 +2,5 @@
|
|||
<resources>
|
||||
<color name="accent">#99cc00</color>
|
||||
<color name="divider">#8A000000</color>
|
||||
<color name="red">#D32F2F</color>
|
||||
</resources>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
<privapp-permissions package="com.stevesoltys.seedvault">
|
||||
<permission name="android.permission.BACKUP"/>
|
||||
<permission name="android.permission.MANAGE_USB"/>
|
||||
<permission name="android.permission.INSTALL_PACKAGES"/>
|
||||
<permission name="android.permission.WRITE_SECURE_SETTINGS"/>
|
||||
</privapp-permissions>
|
||||
</permissions>
|
Loading…
Reference in a new issue