diff --git a/README.md b/README.md index ae9ddbd4..304c0044 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,15 @@ A backup application for the [Android Open Source Project](https://source.androi AOSP. ## What makes this different? -This application is compiled with the operating system and does not require a rooted device for use. It uses the same -internal APIs as `adb backup` and requires a minimal number of permissions to achieve this. +This application is compiled with the operating system and does not require a rooted device for use. +It uses the same internal APIs as `adb backup` which is deprecated and thus needs a replacement. ## Permissions * `android.permission.BACKUP` to back up application data. * `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots. * `android.permission.MANAGE_USB` to access the serial number of USB mass storage devices. * `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings. +* `android.permission.INSTALL_PACKAGES` to re-install apps when restoring from backup. ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/stevesoltys/seedvault. diff --git a/app/build.gradle b/app/build.gradle index 82fb5174..1ed8157f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -119,6 +119,8 @@ dependencies { implementation 'androidx.preference:preference-ktx:1.1.0' implementation 'com.google.android.material:material:1.0.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0-rc03' implementation 'androidx.constraintlayout:constraintlayout:1.1.3' lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a792b726..dc86fdff 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,6 +24,11 @@ android:name="android.permission.WRITE_SECURE_SETTINGS" tools:ignore="ProtectedPermissions" /> + + + + backupNameView.text = restorableBackup.name + }) + + viewModel.installResult.observe(this, Observer { result -> + onInstallResult(result) + }) + } + + private fun onInstallResult(installResult: InstallResult) { + installResult.getInProgress()?.let { result -> + currentPackageView.text = result.name + result.icon?.let { currentPackageImageView.setImageDrawable(it) } + progressBar.progress = result.progress + progressBar.max = result.total + } + // TODO add finished apps to list of (failed?) apps and continue on button press + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt new file mode 100644 index 00000000..34cab843 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt @@ -0,0 +1,22 @@ +package com.stevesoltys.seedvault.restore + +import android.app.backup.RestoreSet +import com.stevesoltys.seedvault.metadata.BackupMetadata +import com.stevesoltys.seedvault.metadata.PackageMetadataMap + +data class RestorableBackup(private val restoreSet: RestoreSet, + private val backupMetadata: BackupMetadata) { + + val name: String + get() = restoreSet.name + + val token: Long + get() = restoreSet.token + + val time: Long + get() = backupMetadata.time + + val packageMetadataMap: PackageMetadataMap + get() = backupMetadata.packageMetadataMap + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt index 5f4a87bf..ff911602 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt @@ -2,8 +2,10 @@ package com.stevesoltys.seedvault.restore import android.os.Bundle import androidx.annotation.CallSuper -import androidx.lifecycle.Observer import com.stevesoltys.seedvault.R +import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS +import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP +import com.stevesoltys.seedvault.ui.LiveEventHandler import com.stevesoltys.seedvault.ui.RequireProvisioningActivity import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import org.koin.androidx.viewmodel.ext.android.viewModel @@ -21,8 +23,12 @@ class RestoreActivity : RequireProvisioningActivity() { setContentView(R.layout.activity_fragment_container) - viewModel.chosenRestoreSet.observe(this, Observer { set -> - if (set != null) showFragment(RestoreProgressFragment()) + viewModel.displayFragment.observeEvent(this, LiveEventHandler { fragment -> + when (fragment) { + RESTORE_APPS -> showFragment(InstallProgressFragment()) + RESTORE_BACKUP -> showFragment(RestoreProgressFragment()) + else -> throw AssertionError() + } }) if (savedInstanceState == null) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt index faab9c0f..cd877a7b 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt @@ -8,20 +8,18 @@ import android.view.View.INVISIBLE import android.view.View.VISIBLE import android.view.ViewGroup import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON +import androidx.core.content.ContextCompat.getColor import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.getAppName import com.stevesoltys.seedvault.isDebugBuild -import com.stevesoltys.seedvault.settings.SettingsManager import kotlinx.android.synthetic.main.fragment_restore_progress.* -import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.sharedViewModel class RestoreProgressFragment : Fragment() { private val viewModel: RestoreViewModel by sharedViewModel() - private val settingsManager: SettingsManager by inject() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -34,8 +32,8 @@ class RestoreProgressFragment : Fragment() { // decryption will fail when the device is locked, so keep the screen on to prevent locking requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON) - viewModel.chosenRestoreSet.observe(this, Observer { set -> - backupNameView.text = set.device + viewModel.chosenRestorableBackup.observe(this, Observer { restorableBackup -> + backupNameView.text = restorableBackup.name }) viewModel.restoreProgress.observe(this, Observer { currentPackage -> @@ -44,22 +42,14 @@ class RestoreProgressFragment : Fragment() { currentPackageView.text = getString(R.string.restore_current_package, displayName) }) - viewModel.restoreFinished.observe(this, Observer { finished -> + viewModel.restoreBackupResult.observe(this, Observer { finished -> progressBar.visibility = INVISIBLE button.visibility = VISIBLE - if (finished == 0) { - // success - currentPackageView.text = getString(R.string.restore_finished_success) - warningView.text = if (settingsManager.getStorage()?.isUsb == true) { - getString(R.string.restore_finished_warning_only_installed, getString(R.string.restore_finished_warning_ejectable)) - } else { - getString(R.string.restore_finished_warning_only_installed, null) - } - warningView.visibility = VISIBLE + if (finished.hasError()) { + currentPackageView.text = finished.errorMsg + currentPackageView.setTextColor(getColor(requireContext(), R.color.red)) } else { - // error - currentPackageView.text = getString(R.string.restore_finished_error) - currentPackageView.setTextColor(warningView.textColors) + currentPackageView.text = getString(R.string.restore_finished_success) } activity?.window?.clearFlags(FLAG_KEEP_SCREEN_ON) }) diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt index 35d96628..f31ab1e1 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt @@ -1,6 +1,6 @@ package com.stevesoltys.seedvault.restore -import android.app.backup.RestoreSet +import android.text.format.DateUtils.* import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -11,8 +11,8 @@ import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.restore.RestoreSetAdapter.RestoreSetViewHolder internal class RestoreSetAdapter( - private val listener: RestoreSetClickListener, - private val items: Array) : Adapter() { + private val listener: RestorableBackupClickListener, + private val items: List) : Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RestoreSetViewHolder { val v = LayoutInflater.from(parent.context) @@ -31,10 +31,18 @@ internal class RestoreSetAdapter( private val titleView = v.findViewById(R.id.titleView) private val subtitleView = v.findViewById(R.id.subtitleView) - internal fun bind(item: RestoreSet) { - v.setOnClickListener { listener.onRestoreSetClicked(item) } + internal fun bind(item: RestorableBackup) { + v.setOnClickListener { listener.onRestorableBackupClicked(item) } titleView.text = item.name - subtitleView.text = "Android Backup" // TODO change to backup date when available + + val lastBackup = getRelativeTime(item.time) + val setup = getRelativeTime(item.token) + subtitleView.text = v.context.getString(R.string.restore_restore_set_times, lastBackup, setup) + } + + private fun getRelativeTime(time: Long): CharSequence { + val now = System.currentTimeMillis() + return getRelativeTimeSpanString(time, now, HOUR_IN_MILLIS, FORMAT_ABBREV_RELATIVE) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt index fe4852dd..bcc4a2d0 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt @@ -1,12 +1,12 @@ package com.stevesoltys.seedvault.restore -import android.app.backup.RestoreSet import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.View.INVISIBLE import android.view.View.VISIBLE import android.view.ViewGroup +import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import com.stevesoltys.seedvault.R @@ -25,7 +25,10 @@ class RestoreSetFragment : Fragment() { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) - viewModel.restoreSets.observe(this, Observer { result -> onRestoreSetsLoaded(result) }) + // decryption will fail when the device is locked, so keep the screen on to prevent locking + requireActivity().window.addFlags(FLAG_KEEP_SCREEN_ON) + + viewModel.restoreSetResults.observe(this, Observer { result -> onRestoreResultsLoaded(result) }) backView.setOnClickListener { requireActivity().finishAfterTransition() } } @@ -37,24 +40,24 @@ class RestoreSetFragment : Fragment() { } } - private fun onRestoreSetsLoaded(result: RestoreSetResult) { - if (result.hasError()) { + private fun onRestoreResultsLoaded(results: RestoreSetResult) { + if (results.hasError()) { errorView.visibility = VISIBLE listView.visibility = INVISIBLE progressBar.visibility = INVISIBLE - errorView.text = result.errorMsg + errorView.text = results.errorMsg } else { errorView.visibility = INVISIBLE listView.visibility = VISIBLE progressBar.visibility = INVISIBLE - listView.adapter = RestoreSetAdapter(viewModel, result.sets) + listView.adapter = RestoreSetAdapter(viewModel, results.restorableBackups) } } } -internal interface RestoreSetClickListener { - fun onRestoreSetClicked(set: RestoreSet) +internal interface RestorableBackupClickListener { + fun onRestorableBackupClicked(restorableBackup: RestorableBackup) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt index 1c3524f9..47a49abc 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt @@ -5,18 +5,39 @@ import android.app.backup.IBackupManager import android.app.backup.IRestoreObserver import android.app.backup.IRestoreSession import android.app.backup.RestoreSet +import android.os.RemoteException import android.os.UserHandle import android.util.Log import androidx.annotation.WorkerThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations.switchMap +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope import com.stevesoltys.seedvault.BackupMonitor import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.crypto.KeyManager +import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS +import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.transport.TRANSPORT_ID -import com.stevesoltys.seedvault.transport.restore.RestorePlugin +import com.stevesoltys.seedvault.transport.restore.ApkRestore +import com.stevesoltys.seedvault.transport.restore.InstallResult +import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator +import com.stevesoltys.seedvault.ui.LiveEvent +import com.stevesoltys.seedvault.ui.MutableLiveEvent import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine private val TAG = RestoreViewModel::class.java.simpleName @@ -24,69 +45,127 @@ internal class RestoreViewModel( app: Application, settingsManager: SettingsManager, keyManager: KeyManager, - private val backupManager: IBackupManager -) : RequireProvisioningViewModel(app, settingsManager, keyManager), RestoreSetClickListener { + private val backupManager: IBackupManager, + private val restoreCoordinator: RestoreCoordinator, + private val apkRestore: ApkRestore, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO +) : RequireProvisioningViewModel(app, settingsManager, keyManager), RestorableBackupClickListener { override val isRestoreOperation = true private var session: IRestoreSession? = null - private var observer: RestoreObserver? = null private val monitor = BackupMonitor() - private val mRestoreSets = MutableLiveData() - internal val restoreSets: LiveData get() = mRestoreSets + private val mDisplayFragment = MutableLiveEvent() + internal val displayFragment: LiveEvent = mDisplayFragment - private val mChosenRestoreSet = MutableLiveData() - internal val chosenRestoreSet: LiveData get() = mChosenRestoreSet + private val mRestoreSetResults = MutableLiveData() + internal val restoreSetResults: LiveData get() = mRestoreSetResults + + private val mChosenRestorableBackup = MutableLiveData() + internal val chosenRestorableBackup: LiveData get() = mChosenRestorableBackup + + internal val installResult: LiveData = switchMap(mChosenRestorableBackup) { backup -> + @Suppress("EXPERIMENTAL_API_USAGE") + getInstallResult(backup) + } private val mRestoreProgress = MutableLiveData() internal val restoreProgress: LiveData get() = mRestoreProgress - private val mRestoreFinished = MutableLiveData() - // Zero on success; a nonzero error code if the restore operation as a whole failed. - internal val restoreFinished: LiveData get() = mRestoreFinished + private val mRestoreBackupResult = MutableLiveData() + internal val restoreBackupResult: LiveData get() = mRestoreBackupResult - internal fun loadRestoreSets() { - val session = this.session ?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID) + @Throws(RemoteException::class) + private fun getOrStartSession(): IRestoreSession { + val session = this.session + ?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID) + ?: throw RemoteException("beginRestoreSessionForUser returned null") this.session = session + return session + } - if (session == null) { - Log.e(TAG, "beginRestoreSession() returned null session") - mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error)) - return + internal fun loadRestoreSets() = viewModelScope.launch { + mRestoreSetResults.value = getAvailableRestoreSets() + } + + private suspend fun getAvailableRestoreSets() = suspendCoroutine { continuation -> + val session = try { + getOrStartSession() + } catch (e: RemoteException) { + Log.e(TAG, "Error starting new session", e) + continuation.resume(RestoreSetResult(app.getString(R.string.restore_set_error))) + return@suspendCoroutine } - val observer = this.observer ?: RestoreObserver() - this.observer = observer + val observer = RestoreObserver(continuation) val setResult = session.getAvailableRestoreSets(observer, monitor) if (setResult != 0) { Log.e(TAG, "getAvailableRestoreSets() returned non-zero value") - mRestoreSets.value = RestoreSetResult(app.getString(R.string.restore_set_error)) - return + continuation.resume(RestoreSetResult(app.getString(R.string.restore_set_error))) + return@suspendCoroutine } } - override fun onRestoreSetClicked(set: RestoreSet) { - val session = this.session - check(session != null) { "Restore set clicked, but no session available" } - session.restoreAll(set.token, observer, monitor) + override fun onRestorableBackupClicked(restorableBackup: RestorableBackup) { + mChosenRestorableBackup.value = restorableBackup + mDisplayFragment.setEvent(RESTORE_APPS) - mChosenRestoreSet.value = set + // re-installing apps will take some time and the session probably times out + // so better close it cleanly and re-open it later + closeSession() + } + + @ExperimentalCoroutinesApi + private fun getInstallResult(restorableBackup: RestorableBackup): LiveData { + return apkRestore.restore(restorableBackup.token, restorableBackup.packageMetadataMap) + .onStart { + Log.d(TAG, "Start InstallResult Flow") + }.catch { e -> + Log.d(TAG, "Exception in InstallResult Flow", e) + }.onCompletion { e -> + Log.d(TAG, "Completed InstallResult Flow", e) + mDisplayFragment.postEvent(RESTORE_BACKUP) + startRestore(restorableBackup.token) + } + .flowOn(ioDispatcher) + .asLiveData() + } + + @WorkerThread + private suspend fun startRestore(token: Long) { + Log.d(TAG, "Starting new restore session to restore backup $token") + + // we need to start a new session and retrieve the restore sets before starting the restore + val restoreSetResult = getAvailableRestoreSets() + if (restoreSetResult.hasError()) { + mRestoreBackupResult.postValue(RestoreBackupResult(app.getString(R.string.restore_finished_error))) + return + } + + // now we can start the restore of all available packages + val observer = RestoreObserver() + val restoreAllResult = session?.restoreAll(token, observer, monitor) ?: 1 + if (restoreAllResult != 0) { + if (session == null) Log.e(TAG, "session was null") + else Log.e(TAG, "restoreAll() returned non-zero value") + mRestoreBackupResult.postValue(RestoreBackupResult(app.getString(R.string.restore_finished_error))) + return + } } override fun onCleared() { super.onCleared() - endSession() + closeSession() } - private fun endSession() { + private fun closeSession() { session?.endRestoreSession() session = null - observer = null } @WorkerThread - private inner class RestoreObserver : IRestoreObserver.Stub() { + private inner class RestoreObserver(private val continuation: Continuation? = null) : IRestoreObserver.Stub() { /** * Supply a list of the restore datasets available from the current transport. @@ -98,11 +177,29 @@ internal class RestoreViewModel( * the current device. If no applicable datasets exist, restoreSets will be null. */ override fun restoreSetsAvailable(restoreSets: Array?) { - if (restoreSets == null || restoreSets.isEmpty()) { - mRestoreSets.postValue(RestoreSetResult(app.getString(R.string.restore_set_empty_result))) + check (continuation != null) { "Getting restore sets without continuation" } + + val result = if (restoreSets == null || restoreSets.isEmpty()) { + RestoreSetResult(app.getString(R.string.restore_set_empty_result)) } else { - mRestoreSets.postValue(RestoreSetResult(restoreSets)) + val backupMetadata = restoreCoordinator.getAndClearBackupMetadata() + if (backupMetadata == null) { + Log.e(TAG, "RestoreCoordinator#getAndClearBackupMetadata() returned null") + RestoreSetResult(app.getString(R.string.restore_set_error)) + } else { + val restorableBackups = restoreSets.mapNotNull { set -> + val metadata = backupMetadata[set.token] + if (metadata == null) { + Log.e(TAG, "RestoreCoordinator#getAndClearBackupMetadata() has no metadata for token ${set.token}.") + null + } else { + RestorableBackup(set, metadata) + } + } + RestoreSetResult(restorableBackups) + } } + continuation.resume(result) } /** @@ -135,8 +232,12 @@ internal class RestoreViewModel( * as a whole failed. */ override fun restoreFinished(result: Int) { - mRestoreFinished.postValue(result) - endSession() + val restoreResult = RestoreBackupResult( + if (result == 0) null + else app.getString(R.string.restore_finished_error) + ) + mRestoreBackupResult.postValue(restoreResult) + closeSession() } } @@ -144,12 +245,18 @@ internal class RestoreViewModel( } internal class RestoreSetResult( - internal val sets: Array, + internal val restorableBackups: List, internal val errorMsg: String?) { - internal constructor(sets: Array) : this(sets, null) + internal constructor(restorableBackups: List) : this(restorableBackups, null) - internal constructor(errorMsg: String) : this(emptyArray(), errorMsg) + internal constructor(errorMsg: String) : this(emptyList(), errorMsg) internal fun hasError(): Boolean = errorMsg != null } + +internal class RestoreBackupResult(val errorMsg: String? = null) { + internal fun hasError(): Boolean = errorMsg != null +} + +internal enum class DisplayFragment { RESTORE_APPS, RESTORE_BACKUP } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkInstaller.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkInstaller.kt new file mode 100644 index 00000000..b32e13b5 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkInstaller.kt @@ -0,0 +1,91 @@ +package com.stevesoltys.seedvault.transport.restore + +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_UPDATE_CURRENT +import android.content.* +import android.content.Intent.FLAG_RECEIVER_FOREGROUND +import android.content.pm.PackageInstaller +import android.content.pm.PackageInstaller.* +import android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL +import android.content.pm.PackageManager +import android.util.Log +import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.FAILED +import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.SUCCEEDED +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import java.io.File +import java.io.IOException + +private val TAG: String = ApkInstaller::class.java.simpleName + +private const val BROADCAST_ACTION = "com.android.packageinstaller.ACTION_INSTALL_COMMIT" + +internal class ApkInstaller(private val context: Context) { + + private val pm: PackageManager = context.packageManager + private val installer: PackageInstaller = pm.packageInstaller + + @ExperimentalCoroutinesApi + @Throws(IOException::class, SecurityException::class) + internal fun install(cachedApk: File, packageName: String, installerPackageName: String?, installResult: MutableInstallResult) = callbackFlow { + val broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, i: Intent) { + if (i.action != BROADCAST_ACTION) return + offer(onBroadcastReceived(i, packageName, cachedApk, installResult)) + close() + } + } + context.registerReceiver(broadcastReceiver, IntentFilter(BROADCAST_ACTION)) + + install(cachedApk, installerPackageName) + + awaitClose { context.unregisterReceiver(broadcastReceiver) } + } + + private fun install(cachedApk: File, installerPackageName: String?) { + val sessionParams = SessionParams(MODE_FULL_INSTALL).apply { + setInstallerPackageName(installerPackageName) + } + // Don't set more sessionParams intentionally here. + // We saw strange permission issues when doing setInstallReason() or setting installFlags. + @Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO + val session = installer.openSession(installer.createSession(sessionParams)) + val sizeBytes = cachedApk.length() + session.use { s -> + cachedApk.inputStream().use { inputStream -> + s.openWrite("PackageInstaller", 0, sizeBytes).use { out -> + inputStream.copyTo(out) + s.fsync(out) + } + } + s.commit(getIntentSender()) + } + } + + private fun getIntentSender(): IntentSender { + val broadcastIntent = Intent(BROADCAST_ACTION).apply { + flags = FLAG_RECEIVER_FOREGROUND + setPackage(context.packageName) + } + val pendingIntent = PendingIntent.getBroadcast(context, 0, broadcastIntent, FLAG_UPDATE_CURRENT) + return pendingIntent.intentSender + } + + private fun onBroadcastReceived(i: Intent, expectedPackageName: String, cachedApk: File, installResult: MutableInstallResult): InstallResult { + val packageName = i.getStringExtra(EXTRA_PACKAGE_NAME)!! + val success = i.getIntExtra(EXTRA_STATUS, -1) == STATUS_SUCCESS + val statusMsg = i.getStringExtra(EXTRA_STATUS_MESSAGE)!! + + check(packageName == expectedPackageName) { "Expected $expectedPackageName, but got $packageName." } + Log.d(TAG, "Received result for $packageName: success=$success $statusMsg") + + // delete cached APK file + cachedApk.delete() + + // update status and offer result + val status = if (success) SUCCEEDED else FAILED + return installResult.update(packageName) { it.copy(status = status) } + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkRestore.kt new file mode 100644 index 00000000..01a52f35 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/ApkRestore.kt @@ -0,0 +1,167 @@ +package com.stevesoltys.seedvault.transport.restore + +import android.content.Context +import android.content.pm.PackageManager.GET_SIGNATURES +import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES +import android.graphics.drawable.Drawable +import android.util.Log +import com.stevesoltys.seedvault.encodeBase64 +import com.stevesoltys.seedvault.metadata.PackageMetadata +import com.stevesoltys.seedvault.metadata.PackageMetadataMap +import com.stevesoltys.seedvault.transport.backup.getSignatures +import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import java.io.File +import java.io.IOException +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap + + +private val TAG = ApkRestore::class.java.simpleName + +internal class ApkRestore( + private val context: Context, + private val restorePlugin: RestorePlugin, + private val apkInstaller: ApkInstaller = ApkInstaller(context)) { + + private val pm = context.packageManager + + @ExperimentalCoroutinesApi + fun restore(token: Long, packageMetadataMap: PackageMetadataMap) = flow { + // filter out packages without APK and get total + val packages = packageMetadataMap.filter { it.value.hasApk() } + val total = packages.size + var progress = 0 + + // queue all packages and emit LiveData + val installResult = MutableInstallResult(total) + packages.forEach { (packageName, _) -> + progress++ + installResult[packageName] = ApkRestoreResult(progress, total, QUEUED) + } + emit(installResult) + + // restore individual packages and emit updates + for ((packageName, metadata) in packages) { + try { + @Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO + restore(token, packageName, metadata, installResult).collect { + emit(it) + } + } catch (e: IOException) { + Log.e(TAG, "Error re-installing APK for $packageName.", e) + emit(fail(installResult, packageName)) + } catch (e: SecurityException) { + Log.e(TAG, "Security error re-installing APK for $packageName.", e) + emit(fail(installResult, packageName)) + } catch (e: TimeoutCancellationException) { + Log.e(TAG, "Timeout while re-installing APK for $packageName.", e) + emit(fail(installResult, packageName)) + } + } + } + + @ExperimentalCoroutinesApi + @Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO + @Throws(IOException::class, SecurityException::class) + private fun restore(token: Long, packageName: String, metadata: PackageMetadata, installResult: MutableInstallResult) = flow { + // create a cache file to write the APK into + val cachedApk = File.createTempFile(packageName, ".apk", context.cacheDir) + // copy APK to cache file and calculate SHA-256 hash while we are at it + val messageDigest = MessageDigest.getInstance("SHA-256") + restorePlugin.getApkInputStream(token, packageName).use { inputStream -> + cachedApk.outputStream().use { outputStream -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = inputStream.read(buffer) + while (bytes >= 0) { + outputStream.write(buffer, 0, bytes) + messageDigest.update(buffer, 0, bytes) + bytes = inputStream.read(buffer) + } + } + } + + // check APK's SHA-256 hash + val sha256 = messageDigest.digest().encodeBase64() + if (metadata.sha256 != sha256) { + throw SecurityException("Package $packageName has sha256 '$sha256', but '${metadata.sha256}' expected.") + } + + // parse APK (GET_SIGNATURES is needed even though deprecated) + @Suppress("DEPRECATION") val flags = GET_SIGNING_CERTIFICATES or GET_SIGNATURES + val packageInfo = pm.getPackageArchiveInfo(cachedApk.absolutePath, flags) + ?: throw IOException("getPackageArchiveInfo returned null") + + // check APK package name + if (packageName != packageInfo.packageName) { + throw SecurityException("Package $packageName expected, but ${packageInfo.packageName} found.") + } + + // check APK version code + if (metadata.version != packageInfo.longVersionCode) { + Log.w(TAG, "Package $packageName expects version code ${metadata.version}, but has ${packageInfo.longVersionCode}.") + // TODO should we let this one pass, maybe once we can revert PackageMetadata during backup? + } + + // check signatures + if (metadata.signatures != packageInfo.signingInfo.getSignatures()) { + Log.w(TAG, "Package $packageName expects different signatures.") + // TODO should we let this one pass, the sha256 hash already verifies the APK? + } + + // get app icon and label (name) + val appInfo = packageInfo.applicationInfo.apply { + // set APK paths before, so package manager can find it for icon extraction + sourceDir = cachedApk.absolutePath + publicSourceDir = cachedApk.absolutePath + } + val icon = appInfo.loadIcon(pm) + val name = pm.getApplicationLabel(appInfo) ?: packageName + + installResult.update(packageName) { it.copy(status = IN_PROGRESS, name = name, icon = icon) } + emit(installResult) + + // install APK and emit updates from it + apkInstaller.install(cachedApk, packageName, metadata.installer, installResult).collect { result -> + emit(result) + } + } + + private fun fail(installResult: MutableInstallResult, packageName: String): InstallResult { + return installResult.update(packageName) { it.copy(status = FAILED) } + } + +} + +internal typealias InstallResult = Map + +internal fun InstallResult.getInProgress(): ApkRestoreResult? { + val filtered = filterValues { result -> result.status == IN_PROGRESS } + if (filtered.isEmpty()) return null + check(filtered.size == 1) { "More than one package in progress: ${filtered.keys}" } + return filtered.values.first() +} + +internal class MutableInstallResult(initialCapacity: Int) : ConcurrentHashMap(initialCapacity) { + fun update(packageName: String, updateFun: (ApkRestoreResult) -> ApkRestoreResult): MutableInstallResult { + val result = get(packageName) + check(result != null) { "ApkRestoreResult for $packageName does not exist." } + set(packageName, updateFun(result)) + return this + } +} + +internal data class ApkRestoreResult( + val progress: Int, + val total: Int, + val status: ApkRestoreStatus, + val name: CharSequence? = null, + val icon: Drawable? = null +) + +internal enum class ApkRestoreStatus { + QUEUED, IN_PROGRESS, SUCCEEDED, FAILED +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt index d3531129..39aad112 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt @@ -2,13 +2,16 @@ package com.stevesoltys.seedvault.transport.restore import android.app.backup.BackupTransport.TRANSPORT_ERROR import android.app.backup.BackupTransport.TRANSPORT_OK +import android.app.backup.IBackupManager import android.app.backup.RestoreDescription import android.app.backup.RestoreDescription.* import android.app.backup.RestoreSet import android.content.pm.PackageInfo import android.os.ParcelFileDescriptor import android.util.Log +import androidx.collection.LongSparseArray import com.stevesoltys.seedvault.header.UnsupportedVersionException +import com.stevesoltys.seedvault.metadata.BackupMetadata import com.stevesoltys.seedvault.metadata.DecryptionFailedException import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataReader @@ -29,6 +32,7 @@ internal class RestoreCoordinator( private val metadataReader: MetadataReader) { private var state: RestoreCoordinatorState? = null + private var backupMetadata: LongSparseArray? = null /** * Get the set of all backups currently available over this transport. @@ -39,6 +43,7 @@ internal class RestoreCoordinator( fun getAvailableRestoreSets(): Array? { val availableBackups = plugin.getAvailableBackups() ?: return null val restoreSets = ArrayList() + val metadataMap = LongSparseArray() for (encryptedMetadata in availableBackups) { if (encryptedMetadata.error) continue check(encryptedMetadata.inputStream != null) { @@ -46,6 +51,7 @@ internal class RestoreCoordinator( } try { val metadata = metadataReader.readMetadata(encryptedMetadata.inputStream, encryptedMetadata.token) + metadataMap.put(encryptedMetadata.token, metadata) val set = RestoreSet(metadata.deviceName, metadata.deviceName, metadata.token) restoreSets.add(set) } catch (e: IOException) { @@ -65,6 +71,7 @@ internal class RestoreCoordinator( } } Log.i(TAG, "Got available restore sets: $restoreSets") + this.backupMetadata = metadataMap return restoreSets.toTypedArray() } @@ -199,4 +206,16 @@ internal class RestoreCoordinator( if (full.hasState()) full.finishRestore() } + /** + * Call this after calling [IBackupManager.getAvailableRestoreTokenForUser] + * to retrieve additional [BackupMetadata] that is not available in [RestoreSet]. + * + * It will also clear the saved metadata, so that subsequent calls will return null. + */ + fun getAndClearBackupMetadata(): LongSparseArray? { + val result = backupMetadata + backupMetadata = null + return result + } + } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt index 6dfd5b27..a4d9f4a3 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt @@ -1,9 +1,11 @@ package com.stevesoltys.seedvault.transport.restore +import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val restoreModule = module { single { OutputFactory() } + factory { ApkRestore(androidContext(), get()) } single { KVRestore(get().kvRestorePlugin, get(), get(), get()) } single { FullRestore(get().fullRestorePlugin, get(), get(), get()) } single { RestoreCoordinator(get(), get(), get(), get(), get()) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt index 607469d8..750c9b11 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt @@ -3,6 +3,8 @@ package com.stevesoltys.seedvault.transport.restore import android.net.Uri import androidx.annotation.WorkerThread import com.stevesoltys.seedvault.metadata.EncryptedBackupMetadata +import java.io.IOException +import java.io.InputStream interface RestorePlugin { @@ -27,4 +29,10 @@ interface RestorePlugin { @WorkerThread fun hasBackup(uri: Uri): Boolean + /** + * Returns an [InputStream] for the given token, for reading an APK that is to be restored. + */ + @Throws(IOException::class) + fun getApkInputStream(token: Long, packageName: String): InputStream + } diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt index 44864b92..1df09664 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt @@ -4,8 +4,8 @@ import android.app.Application import android.net.Uri import android.util.Log import com.stevesoltys.seedvault.R -import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT +import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.transport.restore.RestorePlugin private val TAG = RestoreStorageViewModel::class.java.simpleName diff --git a/app/src/main/res/layout/fragment_install_progress.xml b/app/src/main/res/layout/fragment_install_progress.xml new file mode 100644 index 00000000..43617d49 --- /dev/null +++ b/app/src/main/res/layout/fragment_install_progress.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_restore_progress.xml b/app/src/main/res/layout/fragment_restore_progress.xml index 49983f4d..1ffbe413 100644 --- a/app/src/main/res/layout/fragment_restore_progress.xml +++ b/app/src/main/res/layout/fragment_restore_progress.xml @@ -66,22 +66,6 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/currentPackageView" /> - -