From fcd4e518a5121ffb5e19d8297ec8ef8b8387953f Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 19 Feb 2024 16:57:55 -0300 Subject: [PATCH 01/22] Move APK backup from BackupCoordinator to new ApkBackupManager This is a preparation for doing APK backup ourselves in a worker and not hacked into the backup transport. The latter was prone to timeouts by the AOSP backup API. With a worker, we have a bit more control and can schedule backups ourselves. --- .../seedvault/e2e/LargeBackupTestBase.kt | 2 +- .../seedvault/metadata/MetadataManager.kt | 80 ++++--- .../seedvault/restore/install/ApkRestore.kt | 4 +- .../transport/ConfigurableBackupTransport.kt | 4 +- .../transport/backup/BackupCoordinator.kt | 87 +------- .../transport/backup/BackupModule.kt | 9 - .../transport/backup/BackupRequester.kt | 1 - .../transport/backup/PackageService.kt | 49 ++--- .../notification/BackupNotificationManager.kt | 127 ++++++----- .../NotificationBackupObserver.kt | 10 +- .../{transport/backup => worker}/ApkBackup.kt | 16 +- .../seedvault/worker/ApkBackupManager.kt | 116 ++++++++++ .../seedvault/worker/WorkerModule.kt | 31 +++ app/src/main/res/values/strings.xml | 3 + .../seedvault/metadata/MetadataManagerTest.kt | 156 +++++++++++--- .../restore/install/ApkBackupRestoreTest.kt | 5 +- .../transport/CoordinatorIntegrationTest.kt | 26 +-- .../transport/backup/BackupCoordinatorTest.kt | 185 +--------------- .../seedvault/worker/ApkBackupManagerTest.kt | 204 ++++++++++++++++++ .../backup => worker}/ApkBackupTest.kt | 32 +-- 20 files changed, 677 insertions(+), 470 deletions(-) rename app/src/main/java/com/stevesoltys/seedvault/{transport/backup => worker}/ApkBackup.kt (96%) create mode 100644 app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt create mode 100644 app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt create mode 100644 app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt rename app/src/test/java/com/stevesoltys/seedvault/{transport/backup => worker}/ApkBackupTest.kt (90%) diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt index 82d2e492..3a49d35e 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt @@ -179,7 +179,7 @@ internal interface LargeBackupTestBase : LargeTestBase { clearMocks(spyBackupNotificationManager) every { - spyBackupNotificationManager.onBackupFinished(any(), any(), any()) + spyBackupNotificationManager.onBackupFinished(any(), any(), any(), any()) } answers { val success = firstArg() assert(success) { "Backup failed." } diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt index 0dc2663f..5c3d1a7a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt @@ -14,9 +14,7 @@ import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.encodeBase64 import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA -import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA -import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.transport.backup.isSystemApp import java.io.FileNotFoundException @@ -36,7 +34,7 @@ internal class MetadataManager( private val crypto: Crypto, private val metadataWriter: MetadataWriter, private val metadataReader: MetadataReader, - private val settingsManager: SettingsManager + private val settingsManager: SettingsManager, ) { private val uninitializedMetadata = BackupMetadata(token = 0L, salt = "") @@ -76,42 +74,25 @@ internal class MetadataManager( /** * Call this after a package's APK has been backed up successfully. * - * It updates the packages' metadata - * and writes it encrypted to the given [OutputStream] as well as the internal cache. - * - * Closing the [OutputStream] is the responsibility of the caller. + * It updates the packages' metadata to the internal cache. + * You still need to call [uploadMetadata] to persist all local modifications. */ @Synchronized @Throws(IOException::class) fun onApkBackedUp( packageInfo: PackageInfo, packageMetadata: PackageMetadata, - metadataOutputStream: OutputStream, ) { val packageName = packageInfo.packageName metadata.packageMetadataMap[packageName]?.let { check(packageMetadata.version != null) { "APK backup returned version null" } - check(it.version == null || it.version < packageMetadata.version) { - "APK backup backed up the same or a smaller version:" + - "was ${it.version} is ${packageMetadata.version}" - } } val oldPackageMetadata = metadata.packageMetadataMap[packageName] ?: PackageMetadata() - // only allow state change if backup of this package is not allowed, - // because we need to change from the default of UNKNOWN_ERROR here, - // but otherwise don't want to modify the state since set elsewhere. - val newState = - if (packageMetadata.state == NOT_ALLOWED || packageMetadata.state == WAS_STOPPED) { - packageMetadata.state - } else { - oldPackageMetadata.state - } - modifyMetadata(metadataOutputStream) { + modifyCachedMetadata { metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy( - state = newState, system = packageInfo.isSystemApp(), version = packageMetadata.version, installer = packageMetadata.installer, @@ -192,9 +173,60 @@ internal class MetadataManager( } } + /** + * Call this for all packages we can not back up for some reason. + * + * It updates the packages' local metadata. + * You still need to call [uploadMetadata] to persist all local modifications. + */ + @Synchronized + @Throws(IOException::class) + internal fun onPackageDoesNotGetBackedUp( + packageInfo: PackageInfo, + packageState: PackageState, + ) = modifyCachedMetadata { + val packageName = packageInfo.packageName + if (metadata.packageMetadataMap.containsKey(packageName)) { + metadata.packageMetadataMap[packageName]!!.state = packageState + } else { + metadata.packageMetadataMap[packageName] = PackageMetadata( + time = 0L, + state = packageState, + system = packageInfo.isSystemApp(), + ) + } + } + + /** + * Uploads metadata to given [metadataOutputStream] after performing local modifications. + */ + @Synchronized + @Throws(IOException::class) + fun uploadMetadata(metadataOutputStream: OutputStream) { + metadataWriter.write(metadata, metadataOutputStream) + } + + @Throws(IOException::class) + private fun modifyCachedMetadata(modFun: () -> Unit) { + val oldMetadata = metadata.copy( // copy map, otherwise it will re-use same reference + packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap), + ) + try { + modFun.invoke() + writeMetadataToCache() + } catch (e: IOException) { + Log.w(TAG, "Error writing metadata to storage", e) + // revert metadata and do not write it to cache + metadata = oldMetadata + throw IOException(e) + } + } + @Throws(IOException::class) private fun modifyMetadata(metadataOutputStream: OutputStream, modFun: () -> Unit) { - val oldMetadata = metadata.copy() + val oldMetadata = metadata.copy( // copy map, otherwise it will re-use same reference + packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap), + ) try { modFun.invoke() metadataWriter.write(metadata, metadataOutputStream) diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt index a553ce82..4b6ea82d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt @@ -15,9 +15,9 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_A import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED -import com.stevesoltys.seedvault.transport.backup.copyStreamsAndGetHash -import com.stevesoltys.seedvault.transport.backup.getSignatures import com.stevesoltys.seedvault.transport.backup.isSystemApp +import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash +import com.stevesoltys.seedvault.worker.getSignatures import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.flow diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt index cead1eab..11731f67 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt @@ -130,8 +130,8 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup) } - override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long = runBlocking { - backupCoordinator.getBackupQuota(packageName, isFullBackup) + override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { + return backupCoordinator.getBackupQuota(packageName, isFullBackup) } override fun clearBackupData(packageInfo: PackageInfo): Int = runBlocking { diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt index f1dde3b1..1dfbbce0 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt @@ -13,21 +13,17 @@ import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED import android.app.backup.RestoreSet import android.content.Context import android.content.pm.PackageInfo -import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.os.ParcelFileDescriptor import android.util.Log -import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import com.stevesoltys.seedvault.Clock import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.metadata.BackupType import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.PackageState -import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR -import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.settings.SettingsManager @@ -65,7 +61,6 @@ internal class BackupCoordinator( private val plugin: StoragePlugin, private val kv: KVBackup, private val full: FullBackup, - private val apkBackup: ApkBackup, private val clock: Clock, private val packageService: PackageService, private val metadataManager: MetadataManager, @@ -156,13 +151,7 @@ internal class BackupCoordinator( * otherwise for key-value backup. * @return Current limit on backup size in bytes. */ - suspend fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { - if (packageName != MAGIC_PACKAGE_MANAGER) { - // try to back up APK here as later methods are sometimes not called - // TODO move this into BackupWorker - backUpApk(context.packageManager.getPackageInfo(packageName, GET_SIGNING_CERTIFICATES)) - } - + fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { // report back quota Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.") val quota = if (isFullBackup) full.getQuota() else kv.getQuota() @@ -369,9 +358,9 @@ internal class BackupCoordinator( // tell K/V backup to finish var result = kv.finishBackup() if (result == TRANSPORT_OK) { - val isPmBackup = packageName == MAGIC_PACKAGE_MANAGER + val isNormalBackup = packageName != MAGIC_PACKAGE_MANAGER // call onPackageBackedUp for @pm@ only if we can do backups right now - if (!isPmBackup || settingsManager.canDoBackupNow()) { + if (isNormalBackup || settingsManager.canDoBackupNow()) { try { onPackageBackedUp(packageInfo, BackupType.KV, size) } catch (e: Exception) { @@ -379,17 +368,6 @@ internal class BackupCoordinator( result = TRANSPORT_PACKAGE_REJECTED } } - // hook in here to back up APKs of apps that are otherwise not allowed for backup - // TODO move this into BackupWorker - if (isPmBackup && settingsManager.canDoBackupNow()) { - try { - backUpApksOfNotBackedUpPackages() - } catch (e: Exception) { - Log.e(TAG, "Error backing up APKs of opt-out apps: ", e) - // We are re-throwing this, because we want to know about problems here - throw e - } - } } result } @@ -418,65 +396,6 @@ internal class BackupCoordinator( else -> throw IllegalStateException("Unexpected state in finishBackup()") } - @VisibleForTesting - internal suspend fun backUpApksOfNotBackedUpPackages() { - Log.d(TAG, "Checking if APKs of opt-out apps need backup...") - val notBackedUpPackages = packageService.notBackedUpPackages - notBackedUpPackages.forEachIndexed { i, packageInfo -> - val packageName = packageInfo.packageName - try { - nm.onOptOutAppBackup(packageName, i + 1, notBackedUpPackages.size) - val packageState = if (packageInfo.isStopped()) WAS_STOPPED else NOT_ALLOWED - val wasBackedUp = backUpApk(packageInfo, packageState) - if (wasBackedUp) { - Log.d(TAG, "Was backed up: $packageName") - } else { - Log.d(TAG, "Not backed up: $packageName - ${packageState.name}") - val packageMetadata = - metadataManager.getPackageMetadata(packageName) - val oldPackageState = packageMetadata?.state - if (oldPackageState != packageState) { - Log.i( - TAG, "Package $packageName was in $oldPackageState" + - ", update to $packageState" - ) - plugin.getMetadataOutputStream().use { - metadataManager.onPackageBackupError(packageInfo, packageState, it) - } - } - } - } catch (e: IOException) { - Log.e(TAG, "Error backing up opt-out APK of $packageName", e) - } - } - } - - /** - * Backs up an APK for the given [PackageInfo]. - * - * @return true if a backup was performed and false if no backup was needed or it failed. - */ - private suspend fun backUpApk( - packageInfo: PackageInfo, - packageState: PackageState = UNKNOWN_ERROR, - ): Boolean { - val packageName = packageInfo.packageName - return try { - apkBackup.backupApkIfNecessary(packageInfo, packageState) { name -> - val token = settingsManager.getToken() ?: throw IOException("no current token") - plugin.getOutputStream(token, name) - }?.let { packageMetadata -> - plugin.getMetadataOutputStream().use { - metadataManager.onApkBackedUp(packageInfo, packageMetadata, it) - } - true - } ?: false - } catch (e: IOException) { - Log.e(TAG, "Error while writing APK or metadata for $packageName", e) - false - } - } - private suspend fun onPackageBackedUp(packageInfo: PackageInfo, type: BackupType, size: Long?) { plugin.getMetadataOutputStream().use { metadataManager.onPackageBackedUp(packageInfo, type, size, it) diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt index bf7d3272..3ed9caed 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt @@ -13,14 +13,6 @@ val backupModule = module { plugin = get() ) } - single { - ApkBackup( - pm = androidContext().packageManager, - crypto = get(), - settingsManager = get(), - metadataManager = get() - ) - } single { KvDbManagerImpl(androidContext()) } single { KVBackup( @@ -45,7 +37,6 @@ val backupModule = module { plugin = get(), kv = get(), full = get(), - apkBackup = get(), clock = get(), packageService = get(), metadataManager = get(), diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupRequester.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupRequester.kt index 79c79ba9..209db762 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupRequester.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupRequester.kt @@ -39,7 +39,6 @@ internal class BackupRequester( context = context, backupRequester = this, requestedPackages = packages.size, - appTotals = packageService.expectedAppTotals, ) private val monitor = BackupMonitor() diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt index 58afe8d5..2aa126cc 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt @@ -73,6 +73,22 @@ internal class PackageService( return packageArray } + /** + * A list of packages that is installed and that we need to re-install for restore, + * such as user-installed packages or updated system apps. + */ + val allUserPackages: List + @WorkerThread + get() { + // We need the GET_SIGNING_CERTIFICATES flag here, + // because the package info is used by [ApkBackup] which needs signing info. + return packageManager.getInstalledPackages(GET_SIGNING_CERTIFICATES) + .filter { packageInfo -> // only apps that are: + !packageInfo.isNotUpdatedSystemApp() && // not vanilla system apps + packageInfo.packageName != context.packageName // not this app + } + } + /** * A list of packages that will not be backed up, * because they are currently force-stopped for example. @@ -90,9 +106,9 @@ internal class PackageService( }.sortedBy { packageInfo -> packageInfo.packageName }.also { notAllowed -> - // log eligible packages + // log packages that don't get backed up if (Log.isLoggable(TAG, INFO)) { - Log.i(TAG, "${notAllowed.size} apps do not allow backup:") + Log.i(TAG, "${notAllowed.size} apps do not get backed up:") logPackages(notAllowed.map { it.packageName }) } } @@ -124,22 +140,6 @@ internal class PackageService( } } - val expectedAppTotals: ExpectedAppTotals - @WorkerThread - get() { - var appsTotal = 0 - var appsNotIncluded = 0 - packageManager.getInstalledPackages(GET_INSTRUMENTATION).forEach { packageInfo -> - if (packageInfo.isUserVisible(context)) { - appsTotal++ - if (packageInfo.doesNotGetBackedUp()) { - appsNotIncluded++ - } - } - } - return ExpectedAppTotals(appsTotal, appsNotIncluded) - } - fun getVersionName(packageName: String): String? = try { packageManager.getPackageInfo(packageName, 0).versionName } catch (e: PackageManager.NameNotFoundException) { @@ -208,19 +208,6 @@ internal class PackageService( } } -internal data class ExpectedAppTotals( - /** - * The total number of non-system apps eligible for backup. - */ - val appsTotal: Int, - /** - * The number of non-system apps that do not get backed up. - * These are included here, because we'll at least back up their APKs, - * so at least the app itself does get restored. - */ - val appsNotGettingBackedUp: Int, -) - internal fun PackageInfo.isUserVisible(context: Context): Boolean { if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false return !isNotUpdatedSystemApp() && instrumentation == null && packageName != context.packageName diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt index 3ba30dae..9a97b488 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt @@ -1,6 +1,7 @@ package com.stevesoltys.seedvault.ui.notification import android.annotation.SuppressLint +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.NotificationManager.IMPORTANCE_DEFAULT @@ -26,19 +27,20 @@ import com.stevesoltys.seedvault.restore.EXTRA_PACKAGE_NAME import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST import com.stevesoltys.seedvault.settings.SettingsActivity -import com.stevesoltys.seedvault.transport.backup.ExpectedAppTotals import kotlin.math.min private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver" +private const val CHANNEL_ID_APK = "NotificationApkBackup" private const val CHANNEL_ID_SUCCESS = "NotificationBackupSuccess" private const val CHANNEL_ID_ERROR = "NotificationError" private const val CHANNEL_ID_RESTORE_ERROR = "NotificationRestoreError" private const val NOTIFICATION_ID_OBSERVER = 1 -private const val NOTIFICATION_ID_SUCCESS = 2 -private const val NOTIFICATION_ID_ERROR = 3 -private const val NOTIFICATION_ID_RESTORE_ERROR = 4 -private const val NOTIFICATION_ID_BACKGROUND = 5 -private const val NOTIFICATION_ID_NO_MAIN_KEY_ERROR = 6 +internal const val NOTIFICATION_ID_APK = 2 +private const val NOTIFICATION_ID_SUCCESS = 3 +private const val NOTIFICATION_ID_ERROR = 4 +private const val NOTIFICATION_ID_RESTORE_ERROR = 5 +private const val NOTIFICATION_ID_BACKGROUND = 6 +private const val NOTIFICATION_ID_NO_MAIN_KEY_ERROR = 7 private val TAG = BackupNotificationManager::class.java.simpleName @@ -46,18 +48,11 @@ internal class BackupNotificationManager(private val context: Context) { private val nm = context.getSystemService(NotificationManager::class.java)!!.apply { createNotificationChannel(getObserverChannel()) + createNotificationChannel(getApkChannel()) createNotificationChannel(getSuccessChannel()) createNotificationChannel(getErrorChannel()) createNotificationChannel(getRestoreErrorChannel()) } - private var expectedApps: Int? = null - private var expectedOptOutApps: Int? = null - private var expectedAppTotals: ExpectedAppTotals? = null - - /** - * Used as a (temporary) hack to fix progress reporting when fake d2d is enabled. - */ - private var optOutAppsDone = false private fun getObserverChannel(): NotificationChannel { val title = context.getString(R.string.notification_channel_title) @@ -66,6 +61,13 @@ internal class BackupNotificationManager(private val context: Context) { } } + private fun getApkChannel(): NotificationChannel { + val title = context.getString(R.string.notification_apk_channel_title) + return NotificationChannel(CHANNEL_ID_APK, title, IMPORTANCE_LOW).apply { + enableVibration(false) + } + } + private fun getSuccessChannel(): NotificationChannel { val title = context.getString(R.string.notification_success_channel_title) return NotificationChannel(CHANNEL_ID_SUCCESS, title, IMPORTANCE_LOW).apply { @@ -84,57 +86,69 @@ internal class BackupNotificationManager(private val context: Context) { } /** - * Call this right after starting a backup. + * This should get called for each APK we are backing up. */ - fun onBackupStarted( - expectedPackages: Int, - appTotals: ExpectedAppTotals, - ) { - updateBackupNotification( - infoText = "", // This passes quickly, no need to show something here - transferred = 0, - expected = appTotals.appsTotal - ) - expectedApps = expectedPackages - expectedOptOutApps = appTotals.appsNotGettingBackedUp - expectedAppTotals = appTotals - optOutAppsDone = false - Log.i(TAG, "onBackupStarted $expectedApps + $expectedOptOutApps = ${appTotals.appsTotal}") + fun onApkBackup(packageName: String, name: CharSequence, transferred: Int, expected: Int) { + Log.i(TAG, "$transferred/$expected - $name ($packageName)") + val text = context.getString(R.string.notification_apk_text, name) + val notification = getApkBackupNotification(text, transferred, expected) + nm.notify(NOTIFICATION_ID_APK, notification) } /** - * This should get called before [onBackupUpdate]. - * In case of d2d backups, this actually gets called some time after - * some apps were already backed up, so [onBackupUpdate] was called several times. + * This should get called for recording apps we don't back up. */ - fun onOptOutAppBackup(packageName: String, transferred: Int, expected: Int) { - if (optOutAppsDone) return + fun onAppsNotBackedUp() { + Log.i(TAG, "onAppsNotBackedUp") + val notification = + getApkBackupNotification(context.getString(R.string.notification_apk_not_backed_up)) + nm.notify(NOTIFICATION_ID_APK, notification) + } - val text = "APK for $packageName" - if (expectedApps == null) { - updateBackgroundBackupNotification(text) - } else { - updateBackupNotification(text, transferred, expected + (expectedApps ?: 0)) - if (expectedOptOutApps != null && expectedOptOutApps != expected) { - Log.w(TAG, "Number of packages not getting backed up mismatch: " + - "$expectedOptOutApps != $expected") - } - expectedOptOutApps = expected - if (transferred == expected) optOutAppsDone = true - } + fun getApkBackupNotification( + text: String?, + expected: Int = 0, + transferred: Int = 0, + ): Notification = Builder(context, CHANNEL_ID_APK).apply { + setSmallIcon(R.drawable.ic_cloud_upload) + setContentTitle(context.getString(R.string.notification_title)) + setContentText(text) + setOngoing(true) + setShowWhen(false) + setWhen(System.currentTimeMillis()) + setProgress(expected, transferred, false) + priority = PRIORITY_DEFAULT + foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE + }.build() + + /** + * Call after [onApkBackup] or [onAppsNotBackedUp] were called. + */ + fun onApkBackupDone() { + nm.cancel(NOTIFICATION_ID_APK) + } + + /** + * Call this right after starting a backup. + */ + fun onBackupStarted(expectedPackages: Int) { + updateBackupNotification( + infoText = "", // This passes quickly, no need to show something here + transferred = 0, + expected = expectedPackages + ) + Log.i(TAG, "onBackupStarted - Expecting $expectedPackages apps") } /** * In the series of notification updates, - * this type is is expected to get called after [onOptOutAppBackup]. + * this type is is expected to get called after [onApkBackup]. */ - fun onBackupUpdate(app: CharSequence, transferred: Int) { - val expected = expectedApps ?: error("expectedApps is null") - val addend = expectedOptOutApps ?: 0 + fun onBackupUpdate(app: CharSequence, transferred: Int, total: Int) { updateBackupNotification( infoText = app, - transferred = min(transferred + addend, expected + addend), - expected = expected + addend + transferred = min(transferred, total), + expected = total ) } @@ -197,11 +211,10 @@ internal class BackupNotificationManager(private val context: Context) { // } } - fun onBackupFinished(success: Boolean, numBackedUp: Int?, size: Long) { + fun onBackupFinished(success: Boolean, numBackedUp: Int?, total: Int, size: Long) { val titleRes = if (success) R.string.notification_success_title else R.string.notification_failed_title - val total = expectedAppTotals?.appsTotal - val contentText = if (numBackedUp == null || total == null) null else { + val contentText = if (numBackedUp == null) null else { val sizeStr = Formatter.formatShortFileSize(context, size) context.getString(R.string.notification_success_text, numBackedUp, total, sizeStr) } @@ -224,10 +237,6 @@ internal class BackupNotificationManager(private val context: Context) { }.build() nm.cancel(NOTIFICATION_ID_OBSERVER) nm.notify(NOTIFICATION_ID_SUCCESS, notification) - // reset number of expected apps - expectedOptOutApps = null - expectedApps = null - expectedAppTotals = null } fun hasActiveBackupNotifications(): Boolean { diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt index 47235213..d253970a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt @@ -11,7 +11,6 @@ import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.transport.backup.BackupRequester -import com.stevesoltys.seedvault.transport.backup.ExpectedAppTotals import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -21,7 +20,6 @@ internal class NotificationBackupObserver( private val context: Context, private val backupRequester: BackupRequester, private val requestedPackages: Int, - appTotals: ExpectedAppTotals, ) : IBackupObserver.Stub(), KoinComponent { private val nm: BackupNotificationManager by inject() @@ -31,8 +29,8 @@ internal class NotificationBackupObserver( init { // Inform the notification manager that a backup has started - // and inform about the expected numbers, so it can compute a total. - nm.onBackupStarted(requestedPackages, appTotals) + // and inform about the expected numbers of apps. + nm.onBackupStarted(requestedPackages) } /** @@ -82,7 +80,7 @@ internal class NotificationBackupObserver( val success = status == 0 val numBackedUp = if (success) metadataManager.getPackagesNumBackedUp() else null val size = if (success) metadataManager.getPackagesBackupSize() else 0L - nm.onBackupFinished(success, numBackedUp, size) + nm.onBackupFinished(success, numBackedUp, requestedPackages, size) } } @@ -101,7 +99,7 @@ internal class NotificationBackupObserver( packageName } numPackages += 1 - nm.onBackupUpdate(app, numPackages) + nm.onBackupUpdate(app, numPackages, requestedPackages) } private fun getAppName(packageId: String): CharSequence = getAppName(context, packageId) diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackup.kt similarity index 96% rename from app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt rename to app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackup.kt index c55eb8dd..1f39c3c0 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackup.kt @@ -1,4 +1,9 @@ -package com.stevesoltys.seedvault.transport.backup +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.worker import android.annotation.SuppressLint import android.content.pm.PackageInfo @@ -13,8 +18,9 @@ import com.stevesoltys.seedvault.encodeBase64 import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.PackageMetadata -import com.stevesoltys.seedvault.metadata.PackageState import com.stevesoltys.seedvault.settings.SettingsManager +import com.stevesoltys.seedvault.transport.backup.isNotUpdatedSystemApp +import com.stevesoltys.seedvault.transport.backup.isTestOnly import java.io.File import java.io.FileInputStream import java.io.IOException @@ -44,7 +50,6 @@ internal class ApkBackup( @SuppressLint("NewApi") // can be removed when minSdk is set to 30 suspend fun backupApkIfNecessary( packageInfo: PackageInfo, - packageState: PackageState, streamGetter: suspend (name: String) -> OutputStream, ): PackageMetadata? { // do not back up @pm@ @@ -118,11 +123,10 @@ internal class ApkBackup( val splits = if (packageInfo.splitNames == null) null else backupSplitApks(packageInfo, streamGetter) - Log.d(TAG, "Backed up new APK of $packageName with version $version.") + Log.d(TAG, "Backed up new APK of $packageName with version ${packageInfo.versionName}.") // return updated metadata - return PackageMetadata( - state = packageState, + return packageMetadata.copy( version = version, installer = pm.getInstallSourceInfo(packageName).installingPackageName, splits = splits, diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt new file mode 100644 index 00000000..c75a8608 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.worker + +import android.content.Context +import android.content.pm.PackageInfo +import android.util.Log +import com.stevesoltys.seedvault.metadata.MetadataManager +import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED +import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED +import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA +import com.stevesoltys.seedvault.settings.SettingsManager +import com.stevesoltys.seedvault.transport.backup.PackageService +import com.stevesoltys.seedvault.transport.backup.isStopped +import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager +import com.stevesoltys.seedvault.ui.notification.getAppName +import java.io.IOException +import java.io.OutputStream + +internal class ApkBackupManager( + private val context: Context, + private val settingsManager: SettingsManager, + private val metadataManager: MetadataManager, + private val packageService: PackageService, + private val apkBackup: ApkBackup, + private val plugin: StoragePlugin, + private val nm: BackupNotificationManager, +) { + + companion object { + private val TAG = ApkBackupManager::class.simpleName + } + + suspend fun backup() { + try { + // We may be backing up APKs of packages that don't get their data backed up. + // Since an APK backup does not change the [packageState], we first record it for all + // packages that don't get backed up. + recordNotBackedUpPackages() + // Now, if APK backups are enabled by the user, we back those up. + if (settingsManager.backupApks()) { + backUpApks() + } + } finally { + // upload all local changes only at the end, so we don't have to re-upload the metadata + plugin.getMetadataOutputStream().use { outputStream -> + metadataManager.uploadMetadata(outputStream) + } + nm.onApkBackupDone() + } + } + + /** + * Goes through the list of all apps and uploads their APK, if needed. + */ + private suspend fun backUpApks() { + val apps = packageService.allUserPackages + apps.forEachIndexed { i, packageInfo -> + val packageName = packageInfo.packageName + val name = getAppName(context, packageName) + nm.onApkBackup(packageName, name, i, apps.size) + backUpApk(packageInfo) + } + } + + private fun recordNotBackedUpPackages() { + nm.onAppsNotBackedUp() + packageService.notBackedUpPackages.forEach { packageInfo -> + val packageName = packageInfo.packageName + try { + val packageState = if (packageInfo.isStopped()) WAS_STOPPED else NOT_ALLOWED + val packageMetadata = metadataManager.getPackageMetadata(packageName) + val oldPackageState = packageMetadata?.state + if (oldPackageState != packageState) { + Log.i( + TAG, "Package $packageName was in $oldPackageState" + + ", update to $packageState" + ) + metadataManager.onPackageDoesNotGetBackedUp(packageInfo, packageState) + } + } catch (e: IOException) { + Log.e(TAG, "Error storing new metadata for $packageName: ", e) + } + } + } + + /** + * Backs up an APK for the given [PackageInfo]. + * + * @return true if a backup was performed and false if no backup was needed or it failed. + */ + private suspend fun backUpApk(packageInfo: PackageInfo): Boolean { + val packageName = packageInfo.packageName + return try { + apkBackup.backupApkIfNecessary(packageInfo) { name -> + val token = settingsManager.getToken() ?: throw IOException("no current token") + plugin.getOutputStream(token, name) + }?.let { packageMetadata -> + metadataManager.onApkBackedUp(packageInfo, packageMetadata) + true + } ?: false + } catch (e: IOException) { + Log.e(TAG, "Error while writing APK for $packageName", e) + false + } + } + + private suspend fun StoragePlugin.getMetadataOutputStream(token: Long? = null): OutputStream { + val t = token ?: settingsManager.getToken() ?: throw IOException("no current token") + return getOutputStream(t, FILE_BACKUP_METADATA) + } +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt new file mode 100644 index 00000000..ed18a635 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.worker + +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +val workerModule = module { + single { + ApkBackup( + pm = androidContext().packageManager, + crypto = get(), + settingsManager = get(), + metadataManager = get() + ) + } + single { + ApkBackupManager( + context = androidContext(), + settingsManager = get(), + metadataManager = get(), + packageService = get(), + apkBackup = get(), + plugin = get(), + nm = get() + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a5a5d55c..e8283aaf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -119,8 +119,11 @@ Backup notification + APK backup notification Success notification Backup running + Backing up APK of %s + Saving list of apps we can not back up. Backup already in progress Backup not enabled diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt index d1277132..f3dbb016 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt @@ -28,10 +28,12 @@ import io.mockk.verify import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Before import org.junit.Test +import org.junit.jupiter.api.assertThrows import org.junit.runner.RunWith import org.koin.core.context.stopKoin import org.robolectric.annotation.Config @@ -121,7 +123,7 @@ class MetadataManagerTest { expectReadFromCache() expectModifyMetadata(initialMetadata) - manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream) + manager.onApkBackedUp(packageInfo, packageMetadata) assertEquals(packageMetadata, manager.getPackageMetadata(packageName)) @@ -144,7 +146,7 @@ class MetadataManagerTest { expectReadFromCache() expectModifyMetadata(initialMetadata) - manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream) + manager.onApkBackedUp(packageInfo, packageMetadata) assertEquals(packageMetadata.copy(system = true), manager.getPackageMetadata(packageName)) @@ -171,9 +173,9 @@ class MetadataManagerTest { ) expectReadFromCache() - expectModifyMetadata(initialMetadata) + expectWriteToCache(initialMetadata) - manager.onApkBackedUp(packageInfo, updatedPackageMetadata, storageOutputStream) + manager.onApkBackedUp(packageInfo, updatedPackageMetadata) assertEquals(updatedPackageMetadata, manager.getPackageMetadata(packageName)) @@ -184,7 +186,7 @@ class MetadataManagerTest { } @Test - fun `test onApkBackedUp() limits state changes`() { + fun `test onApkBackedUp() does not change package state`() { var version = Random.nextLong(Long.MAX_VALUE) var packageMetadata = PackageMetadata( version = version, @@ -193,12 +195,12 @@ class MetadataManagerTest { ) expectReadFromCache() - expectModifyMetadata(initialMetadata) + expectWriteToCache(initialMetadata) val oldState = UNKNOWN_ERROR // state doesn't change for APK_AND_DATA packageMetadata = packageMetadata.copy(version = ++version, state = APK_AND_DATA) - manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream) + manager.onApkBackedUp(packageInfo, packageMetadata) assertEquals( packageMetadata.copy(state = oldState), manager.getPackageMetadata(packageName) @@ -206,7 +208,7 @@ class MetadataManagerTest { // state doesn't change for QUOTA_EXCEEDED packageMetadata = packageMetadata.copy(version = ++version, state = QUOTA_EXCEEDED) - manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream) + manager.onApkBackedUp(packageInfo, packageMetadata) assertEquals( packageMetadata.copy(state = oldState), manager.getPackageMetadata(packageName) @@ -214,25 +216,25 @@ class MetadataManagerTest { // state doesn't change for NO_DATA packageMetadata = packageMetadata.copy(version = ++version, state = NO_DATA) - manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream) + manager.onApkBackedUp(packageInfo, packageMetadata) assertEquals( packageMetadata.copy(state = oldState), manager.getPackageMetadata(packageName) ) - // state DOES change for NOT_ALLOWED + // state doesn't change for NOT_ALLOWED packageMetadata = packageMetadata.copy(version = ++version, state = NOT_ALLOWED) - manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream) + manager.onApkBackedUp(packageInfo, packageMetadata) assertEquals( - packageMetadata.copy(state = NOT_ALLOWED), + packageMetadata.copy(state = oldState), manager.getPackageMetadata(packageName) ) - // state DOES change for WAS_STOPPED + // state doesn't change for WAS_STOPPED packageMetadata = packageMetadata.copy(version = ++version, state = WAS_STOPPED) - manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream) + manager.onApkBackedUp(packageInfo, packageMetadata) assertEquals( - packageMetadata.copy(state = WAS_STOPPED), + packageMetadata.copy(state = oldState), manager.getPackageMetadata(packageName) ) @@ -242,6 +244,39 @@ class MetadataManagerTest { } } + @Test + fun `test onApkBackedUp() throws while writing local cache`() { + val packageMetadata = PackageMetadata( + time = 0L, + version = Random.nextLong(Long.MAX_VALUE), + installer = getRandomString(), + signatures = listOf("sig") + ) + + expectReadFromCache() + + assertNull(manager.getPackageMetadata(packageName)) + + every { metadataWriter.encode(initialMetadata) } returns encodedMetadata + every { + context.openFileOutput( + METADATA_CACHE_FILE, + MODE_PRIVATE + ) + } throws FileNotFoundException() + + assertThrows { + manager.onApkBackedUp(packageInfo, packageMetadata) + } + + // metadata change got reverted + assertNull(manager.getPackageMetadata(packageName)) + + verify { + cacheInputStream.close() + } + } + @Test fun `test onPackageBackedUp()`() { packageInfo.applicationInfo.flags = FLAG_SYSTEM @@ -317,10 +352,7 @@ class MetadataManagerTest { } assertEquals(0L, manager.getLastBackupTime()) // time was reverted - assertEquals( - initialMetadata.packageMetadataMap[packageName], - manager.getPackageMetadata(packageName) - ) + assertNull(manager.getPackageMetadata(packageName)) // no package metadata got added verify { cacheInputStream.close() } } @@ -358,6 +390,70 @@ class MetadataManagerTest { } } + @Test + fun `test onPackageDoesNotGetBackedUp() updates state`() { + val updatedMetadata = initialMetadata.copy() + updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = NOT_ALLOWED) + + expectReadFromCache() + expectWriteToCache(updatedMetadata) + + manager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) + + assertEquals( + updatedMetadata.packageMetadataMap[packageName], + manager.getPackageMetadata(packageName), + ) + } + + @Test + fun `test onPackageDoesNotGetBackedUp() creates new state`() { + val updatedMetadata = initialMetadata.copy() + updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = WAS_STOPPED) + initialMetadata.packageMetadataMap.remove(packageName) + + expectReadFromCache() + expectWriteToCache(updatedMetadata) + + manager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED) + + assertEquals( + updatedMetadata.packageMetadataMap[packageName], + manager.getPackageMetadata(packageName), + ) + } + + @Test + fun `test onPackageBackupError() updates state`() { + val updatedMetadata = initialMetadata.copy() + updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = NO_DATA) + + expectReadFromCache() + expectModifyMetadata(updatedMetadata) + + manager.onPackageBackupError(packageInfo, NO_DATA, storageOutputStream, BackupType.KV) + } + + @Test + fun `test onPackageBackupError() inserts new package`() { + val updatedMetadata = initialMetadata.copy() + updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = WAS_STOPPED) + initialMetadata.packageMetadataMap.remove(packageName) + + expectReadFromCache() + expectModifyMetadata(updatedMetadata) + + manager.onPackageBackupError(packageInfo, WAS_STOPPED, storageOutputStream) + } + + @Test + fun `test uploadMetadata() uploads`() { + expectReadFromCache() + every { metadataWriter.write(initialMetadata, storageOutputStream) } just Runs + + manager.uploadMetadata(storageOutputStream) + } + @Test fun `test getBackupToken() on first run`() { every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException() @@ -386,15 +482,7 @@ class MetadataManagerTest { private fun expectModifyMetadata(metadata: BackupMetadata) { every { metadataWriter.write(metadata, storageOutputStream) } just Runs - every { metadataWriter.encode(metadata) } returns encodedMetadata - every { - context.openFileOutput( - METADATA_CACHE_FILE, - MODE_PRIVATE - ) - } returns cacheOutputStream - every { cacheOutputStream.write(encodedMetadata) } just Runs - every { cacheOutputStream.close() } just Runs + expectWriteToCache(metadata) } private fun expectReadFromCache() { @@ -406,4 +494,16 @@ class MetadataManagerTest { every { cacheInputStream.close() } just Runs } + private fun expectWriteToCache(metadata: BackupMetadata) { + every { metadataWriter.encode(metadata) } returns encodedMetadata + every { + context.openFileOutput( + METADATA_CACHE_FILE, + MODE_PRIVATE + ) + } returns cacheOutputStream + every { cacheOutputStream.write(encodedMetadata) } just Runs + every { cacheOutputStream.close() } just Runs + } + } diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt index f712807b..33a244aa 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt @@ -10,12 +10,11 @@ import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadataMap -import com.stevesoltys.seedvault.metadata.PackageState import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.restore.RestorableBackup import com.stevesoltys.seedvault.transport.TransportTest -import com.stevesoltys.seedvault.transport.backup.ApkBackup +import com.stevesoltys.seedvault.worker.ApkBackup import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -121,7 +120,7 @@ internal class ApkBackupRestoreTest : TransportTest() { every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName every { storagePlugin.providerPackageName } returns storageProviderPackageName - apkBackup.backupApkIfNecessary(packageInfo, PackageState.APK_AND_DATA, outputStreamGetter) + apkBackup.backupApkIfNecessary(packageInfo, outputStreamGetter) assertArrayEquals(apkBytes, outputStream.toByteArray()) assertArrayEquals(splitBytes, splitOutputStream.toByteArray()) diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt index 0ff406d2..94d3ea8c 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt @@ -15,11 +15,9 @@ import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH import com.stevesoltys.seedvault.metadata.BackupType import com.stevesoltys.seedvault.metadata.MetadataReaderImpl import com.stevesoltys.seedvault.metadata.PackageMetadata -import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA -import com.stevesoltys.seedvault.transport.backup.ApkBackup import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import com.stevesoltys.seedvault.transport.backup.FullBackup import com.stevesoltys.seedvault.transport.backup.InputFactory @@ -31,6 +29,7 @@ import com.stevesoltys.seedvault.transport.restore.KVRestore import com.stevesoltys.seedvault.transport.restore.OutputFactory import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager +import com.stevesoltys.seedvault.worker.ApkBackup import io.mockk.CapturingSlot import io.mockk.Runs import io.mockk.coEvery @@ -73,7 +72,6 @@ internal class CoordinatorIntegrationTest : TransportTest() { backupPlugin, kvBackup, fullBackup, - apkBackup, clock, packageService, metadataManager, @@ -138,13 +136,13 @@ internal class CoordinatorIntegrationTest : TransportTest() { appData2.size } coEvery { - apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) + apkBackup.backupApkIfNecessary(packageInfo, any()) } returns packageMetadata coEvery { backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream every { - metadataManager.onApkBackedUp(packageInfo, packageMetadata, metadataOutputStream) + metadataManager.onApkBackedUp(packageInfo, packageMetadata) } just Runs every { metadataManager.onPackageBackedUp( @@ -215,7 +213,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { appData.copyInto(value.captured) // write the app data into the passed ByteArray appData.size } - coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null + coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns null every { settingsManager.getToken() } returns token coEvery { backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA) @@ -279,25 +277,13 @@ internal class CoordinatorIntegrationTest : TransportTest() { coEvery { backupPlugin.getOutputStream(token, realName) } returns bOutputStream every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream every { settingsManager.isQuotaUnlimited() } returns false - coEvery { - apkBackup.backupApkIfNecessary( - packageInfo, - UNKNOWN_ERROR, - any() - ) - } returns packageMetadata + coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns packageMetadata every { settingsManager.getToken() } returns token every { metadataManager.salt } returns salt coEvery { backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream - every { - metadataManager.onApkBackedUp( - packageInfo, - packageMetadata, - metadataOutputStream - ) - } just Runs + every { metadataManager.onApkBackedUp(packageInfo, packageMetadata) } just Runs every { metadataManager.onPackageBackedUp( packageInfo = packageInfo, diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt index 30d2aa16..a883aaa4 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt @@ -5,7 +5,6 @@ import android.app.backup.BackupTransport.TRANSPORT_NOT_INITIALIZED import android.app.backup.BackupTransport.TRANSPORT_OK import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED -import android.content.pm.ApplicationInfo.FLAG_STOPPED import android.content.pm.PackageInfo import android.net.Uri import android.os.ParcelFileDescriptor @@ -14,18 +13,15 @@ import com.stevesoltys.seedvault.coAssertThrows import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.metadata.BackupType import com.stevesoltys.seedvault.metadata.PackageMetadata -import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED -import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR -import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.settings.Storage import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager +import com.stevesoltys.seedvault.worker.ApkBackup import io.mockk.Runs import io.mockk.coEvery -import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -36,7 +32,6 @@ import org.junit.jupiter.api.Test import java.io.IOException import java.io.OutputStream import kotlin.random.Random -import kotlin.random.nextLong @Suppress("BlockingMethodInNonBlockingContext") internal class BackupCoordinatorTest : BackupTest() { @@ -53,7 +48,6 @@ internal class BackupCoordinatorTest : BackupTest() { plugin, kv, full, - apkBackup, clock, packageService, metadataManager, @@ -157,16 +151,12 @@ internal class BackupCoordinatorTest : BackupTest() { val isFullBackup = Random.nextBoolean() val quota = Random.nextLong() - expectApkBackupAndMetadataWrite() if (isFullBackup) { every { full.getQuota() } returns quota } else { every { kv.getQuota() } returns quota } - every { metadataOutputStream.close() } just Runs assertEquals(quota, backup.getBackupQuota(packageInfo.packageName, isFullBackup)) - - verify { metadataOutputStream.close() } } @Test @@ -276,7 +266,7 @@ internal class BackupCoordinatorTest : BackupTest() { coEvery { full.performFullBackup(packageInfo, fileDescriptor, 0, token, salt) } returns TRANSPORT_OK - coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null + coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns null assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0)) } @@ -380,180 +370,13 @@ internal class BackupCoordinatorTest : BackupTest() { @Test fun `not allowed apps get their APKs backed up after @pm@ backup`() = runBlocking { - val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER } - val notAllowedPackages = listOf( - PackageInfo().apply { packageName = "org.example.1" }, - PackageInfo().apply { - packageName = "org.example.2" - // the second package does not get backed up, because it is stopped - applicationInfo = mockk { - flags = FLAG_STOPPED - } - } - ) - val packageMetadata: PackageMetadata = mockk() - val size = Random.nextLong(1L..Long.MAX_VALUE) - - every { settingsManager.canDoBackupNow() } returns true - every { metadataManager.requiresInit } returns false - every { settingsManager.getToken() } returns token - every { metadataManager.salt } returns salt - // do actual @pm@ backup - coEvery { - kv.performBackup(packageInfo, fileDescriptor, 0, token, salt) - } returns TRANSPORT_OK - - assertEquals( - TRANSPORT_OK, - backup.performIncrementalBackup(packageInfo, fileDescriptor, 0) - ) - - // finish @pm@ backup - every { kv.hasState() } returns true - every { full.hasState() } returns false - every { kv.getCurrentPackage() } returns pmPackageInfo - every { kv.getCurrentSize() } returns size - every { - metadataManager.onPackageBackedUp( - pmPackageInfo, - BackupType.KV, - size, - metadataOutputStream, - ) - } just Runs - coEvery { kv.finishBackup() } returns TRANSPORT_OK - - // now check if we have opt-out apps that we need to back up APKs for - every { packageService.notBackedUpPackages } returns notAllowedPackages - // update notification - every { - notificationManager.onOptOutAppBackup( - notAllowedPackages[0].packageName, - 1, - notAllowedPackages.size - ) - } just Runs - // no backup needed - coEvery { - apkBackup.backupApkIfNecessary(notAllowedPackages[0], NOT_ALLOWED, any()) - } returns null - // check old metadata for state changes, because we won't update it otherwise - every { - metadataManager.getPackageMetadata(notAllowedPackages[0].packageName) - } returns packageMetadata - every { packageMetadata.state } returns NOT_ALLOWED // no change - - // update notification for second package - every { - notificationManager.onOptOutAppBackup( - notAllowedPackages[1].packageName, - 2, - notAllowedPackages.size - ) - } just Runs - // was backed up, get new packageMetadata - coEvery { - apkBackup.backupApkIfNecessary(notAllowedPackages[1], WAS_STOPPED, any()) - } returns packageMetadata - every { settingsManager.getToken() } returns token - coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream - every { - metadataManager.onApkBackedUp( - notAllowedPackages[1], - packageMetadata, - metadataOutputStream - ) - } just Runs - every { metadataOutputStream.close() } just Runs - - assertEquals(TRANSPORT_OK, backup.finishBackup()) - - coVerify { - apkBackup.backupApkIfNecessary(notAllowedPackages[0], NOT_ALLOWED, any()) - apkBackup.backupApkIfNecessary(notAllowedPackages[1], WAS_STOPPED, any()) - metadataOutputStream.close() - } - } - - @Test - fun `APK backup of not allowed apps updates state even without new APK`() = runBlocking { - val oldPackageMetadata: PackageMetadata = mockk() - - every { packageService.notBackedUpPackages } returns listOf(packageInfo) - every { - notificationManager.onOptOutAppBackup(packageInfo.packageName, 1, 1) - } just Runs - coEvery { apkBackup.backupApkIfNecessary(packageInfo, NOT_ALLOWED, any()) } returns null - every { - metadataManager.getPackageMetadata(packageInfo.packageName) - } returns oldPackageMetadata - // state differs now, was stopped before - every { oldPackageMetadata.state } returns WAS_STOPPED - every { settingsManager.getToken() } returns token - coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream - every { - metadataManager.onPackageBackupError( - packageInfo, - NOT_ALLOWED, - metadataOutputStream - ) - } just Runs - every { metadataOutputStream.close() } just Runs - - backup.backUpApksOfNotBackedUpPackages() - - verify { - metadataManager.onPackageBackupError(packageInfo, NOT_ALLOWED, metadataOutputStream) - metadataOutputStream.close() - } - } - - @Test - fun `APK backup of not allowed apps updates state even without old state`() = runBlocking { - every { packageService.notBackedUpPackages } returns listOf(packageInfo) - every { - notificationManager.onOptOutAppBackup(packageInfo.packageName, 1, 1) - } just Runs - coEvery { apkBackup.backupApkIfNecessary(packageInfo, NOT_ALLOWED, any()) } returns null - every { - metadataManager.getPackageMetadata(packageInfo.packageName) - } returns null - every { settingsManager.getToken() } returns token - coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream - every { - metadataManager.onPackageBackupError( - packageInfo, - NOT_ALLOWED, - metadataOutputStream - ) - } just Runs - every { metadataOutputStream.close() } just Runs - - backup.backUpApksOfNotBackedUpPackages() - - verify { - metadataManager.onPackageBackupError(packageInfo, NOT_ALLOWED, metadataOutputStream) - metadataOutputStream.close() - } } private fun expectApkBackupAndMetadataWrite() { - coEvery { - apkBackup.backupApkIfNecessary( - any(), - UNKNOWN_ERROR, - any() - ) - } returns packageMetadata + coEvery { apkBackup.backupApkIfNecessary(any(), any()) } returns packageMetadata every { settingsManager.getToken() } returns token coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream - every { - metadataManager.onApkBackedUp( - any(), - packageMetadata, - metadataOutputStream - ) - } just Runs + every { metadataManager.onApkBackedUp(any(), packageMetadata) } just Runs } } diff --git a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt new file mode 100644 index 00000000..8f863d3d --- /dev/null +++ b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt @@ -0,0 +1,204 @@ +package com.stevesoltys.seedvault.worker + +import android.content.pm.ApplicationInfo +import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP +import android.content.pm.ApplicationInfo.FLAG_INSTALLED +import android.content.pm.ApplicationInfo.FLAG_STOPPED +import android.content.pm.PackageInfo +import com.stevesoltys.seedvault.metadata.PackageMetadata +import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED +import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR +import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED +import com.stevesoltys.seedvault.plugins.StoragePlugin +import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA +import com.stevesoltys.seedvault.transport.TransportTest +import com.stevesoltys.seedvault.transport.backup.PackageService +import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyAll +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import java.io.OutputStream + +internal class ApkBackupManagerTest : TransportTest() { + + private val packageService: PackageService = mockk() + private val apkBackup: ApkBackup = mockk() + private val plugin: StoragePlugin = mockk() + private val nm: BackupNotificationManager = mockk() + + private val apkBackupManager = ApkBackupManager( + context = context, + settingsManager = settingsManager, + metadataManager = metadataManager, + packageService = packageService, + apkBackup = apkBackup, + plugin = plugin, + nm = nm, + ) + + private val metadataOutputStream = mockk() + private val packageMetadata: PackageMetadata = mockk() + + @Test + fun `Package state of app that is not stopped gets recorded as not-allowed`() = runBlocking { + every { nm.onAppsNotBackedUp() } just Runs + every { packageService.notBackedUpPackages } returns listOf(packageInfo) + + every { + metadataManager.getPackageMetadata(packageInfo.packageName) + } returns packageMetadata + every { packageMetadata.state } returns UNKNOWN_ERROR + every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs + + every { settingsManager.backupApks() } returns false + expectFinalUpload() + every { nm.onApkBackupDone() } just Runs + + apkBackupManager.backup() + + verify { + metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) + metadataOutputStream.close() + } + } + + @Test + fun `Package state of app gets recorded even if no previous state`() = runBlocking { + every { nm.onAppsNotBackedUp() } just Runs + every { packageService.notBackedUpPackages } returns listOf(packageInfo) + + every { + metadataManager.getPackageMetadata(packageInfo.packageName) + } returns null + every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs + + every { settingsManager.backupApks() } returns false + expectFinalUpload() + every { nm.onApkBackupDone() } just Runs + + apkBackupManager.backup() + + verify { + metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) + metadataOutputStream.close() + } + } + + @Test + fun `Package state of app that is stopped gets recorded`() = runBlocking { + val packageInfo = PackageInfo().apply { + packageName = "org.example" + applicationInfo = mockk { + flags = FLAG_ALLOW_BACKUP or FLAG_INSTALLED or FLAG_STOPPED + } + } + + every { nm.onAppsNotBackedUp() } just Runs + every { packageService.notBackedUpPackages } returns listOf(packageInfo) + + every { + metadataManager.getPackageMetadata(packageInfo.packageName) + } returns packageMetadata + every { packageMetadata.state } returns UNKNOWN_ERROR + every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED) } just Runs + + every { settingsManager.backupApks() } returns false + expectFinalUpload() + every { nm.onApkBackupDone() } just Runs + + apkBackupManager.backup() + + verify { + metadataManager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED) + metadataOutputStream.close() + } + } + + @Test + fun `Package state only updated when changed`() = runBlocking { + every { nm.onAppsNotBackedUp() } just Runs + every { packageService.notBackedUpPackages } returns listOf(packageInfo) + + every { + metadataManager.getPackageMetadata(packageInfo.packageName) + } returns packageMetadata + every { packageMetadata.state } returns NOT_ALLOWED + + every { settingsManager.backupApks() } returns false + expectFinalUpload() + every { nm.onApkBackupDone() } just Runs + + apkBackupManager.backup() + + verifyAll(inverse = true) { + metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) + } + } + + @Test + fun `two packages get backed up, one their APK uploaded`() = runBlocking { + val notAllowedPackages = listOf( + PackageInfo().apply { packageName = "org.example.1" }, + PackageInfo().apply { + packageName = "org.example.2" + // the second package does not get backed up, because it is stopped + applicationInfo = mockk { + flags = FLAG_STOPPED + } + } + ) + + expectAllAppsWillGetBackedUp() + every { settingsManager.backupApks() } returns true + + every { packageService.allUserPackages } returns notAllowedPackages + // update notification + every { + nm.onApkBackup(notAllowedPackages[0].packageName, any(), 0, notAllowedPackages.size) + } just Runs + // no backup needed + coEvery { + apkBackup.backupApkIfNecessary(notAllowedPackages[0], any()) + } returns null + // update notification for second package + every { + nm.onApkBackup(notAllowedPackages[1].packageName, any(), 1, notAllowedPackages.size) + } just Runs + // was backed up, get new packageMetadata + coEvery { + apkBackup.backupApkIfNecessary(notAllowedPackages[1], any()) + } returns packageMetadata + every { metadataManager.onApkBackedUp(notAllowedPackages[1], packageMetadata) } just Runs + + expectFinalUpload() + every { nm.onApkBackupDone() } just Runs + + apkBackupManager.backup() + + coVerify { + apkBackup.backupApkIfNecessary(notAllowedPackages[0], any()) + apkBackup.backupApkIfNecessary(notAllowedPackages[1], any()) + metadataOutputStream.close() + } + } + + private fun expectAllAppsWillGetBackedUp() { + every { nm.onAppsNotBackedUp() } just Runs + every { packageService.notBackedUpPackages } returns emptyList() + } + + private fun expectFinalUpload() { + every { settingsManager.getToken() } returns token + coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream + every { metadataManager.uploadMetadata(metadataOutputStream) } just Runs + every { metadataOutputStream.close() } just Runs + } + +} diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupTest.kt similarity index 90% rename from app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt rename to app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupTest.kt index 0cbbf9e0..c56fcd24 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupTest.kt @@ -1,4 +1,9 @@ -package com.stevesoltys.seedvault.transport.backup +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.worker import android.content.pm.ApplicationInfo.FLAG_SYSTEM import android.content.pm.ApplicationInfo.FLAG_TEST_ONLY @@ -13,6 +18,7 @@ import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR +import com.stevesoltys.seedvault.transport.backup.BackupTest import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -56,7 +62,7 @@ internal class ApkBackupTest : BackupTest() { @Test fun `does not back up @pm@`() = runBlocking { val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER } - assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)) + assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) } @Test @@ -64,7 +70,7 @@ internal class ApkBackupTest : BackupTest() { every { settingsManager.backupApks() } returns false every { settingsManager.isBackupEnabled(any()) } returns true - assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)) + assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) } @Test @@ -72,7 +78,7 @@ internal class ApkBackupTest : BackupTest() { every { settingsManager.backupApks() } returns true every { settingsManager.isBackupEnabled(any()) } returns false - assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)) + assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) } @Test @@ -81,7 +87,7 @@ internal class ApkBackupTest : BackupTest() { every { settingsManager.isBackupEnabled(any()) } returns true every { settingsManager.backupApks() } returns true - assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)) + assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) } @Test @@ -90,7 +96,7 @@ internal class ApkBackupTest : BackupTest() { every { settingsManager.isBackupEnabled(any()) } returns true every { settingsManager.backupApks() } returns true - assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)) + assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) } @Test @@ -102,7 +108,7 @@ internal class ApkBackupTest : BackupTest() { expectChecks(packageMetadata) - assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)) + assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) } @Test @@ -113,7 +119,7 @@ internal class ApkBackupTest : BackupTest() { assertThrows(IOException::class.java) { runBlocking { - assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)) + assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) } } } @@ -128,7 +134,7 @@ internal class ApkBackupTest : BackupTest() { every { sigInfo.hasMultipleSigners() } returns false every { sigInfo.signingCertificateHistory } returns emptyArray() - assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)) + assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) } @Test @@ -141,7 +147,7 @@ internal class ApkBackupTest : BackupTest() { }.absolutePath val apkOutputStream = ByteArrayOutputStream() val updatedMetadata = PackageMetadata( - time = 0L, + time = packageMetadata.time, state = UNKNOWN_ERROR, version = packageInfo.longVersionCode, installer = getRandomString(), @@ -159,7 +165,7 @@ internal class ApkBackupTest : BackupTest() { assertEquals( updatedMetadata, - apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter) + apkBackup.backupApkIfNecessary(packageInfo, streamGetter) ) assertArrayEquals(apkBytes, apkOutputStream.toByteArray()) } @@ -198,7 +204,7 @@ internal class ApkBackupTest : BackupTest() { val split2OutputStream = ByteArrayOutputStream() // expected new metadata for package val updatedMetadata = PackageMetadata( - time = 0L, + time = packageMetadata.time, state = UNKNOWN_ERROR, version = packageInfo.longVersionCode, installer = getRandomString(), @@ -231,7 +237,7 @@ internal class ApkBackupTest : BackupTest() { assertEquals( updatedMetadata, - apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter) + apkBackup.backupApkIfNecessary(packageInfo, streamGetter) ) assertArrayEquals(apkBytes, apkOutputStream.toByteArray()) assertArrayEquals(split1Bytes, split1OutputStream.toByteArray()) From 49066be31b05bd84d580bf0a009eed085909ef00 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 20 Feb 2024 15:45:58 -0300 Subject: [PATCH 02/22] Improve backup notification --- .../notification/BackupNotificationManager.kt | 12 ++++------- .../NotificationBackupObserver.kt | 20 ++++++++++++++----- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt index 9a97b488..0a8bc09d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt @@ -133,7 +133,7 @@ internal class BackupNotificationManager(private val context: Context) { */ fun onBackupStarted(expectedPackages: Int) { updateBackupNotification( - infoText = "", // This passes quickly, no need to show something here + appName = "", // This passes quickly, no need to show something here transferred = 0, expected = expectedPackages ) @@ -146,25 +146,21 @@ internal class BackupNotificationManager(private val context: Context) { */ fun onBackupUpdate(app: CharSequence, transferred: Int, total: Int) { updateBackupNotification( - infoText = app, + appName = app, transferred = min(transferred, total), expected = total ) } private fun updateBackupNotification( - infoText: CharSequence, + appName: CharSequence, transferred: Int, expected: Int, ) { - @Suppress("MagicNumber") - val percentage = (transferred.toFloat() / expected) * 100 - val percentageStr = "%.0f%%".format(percentage) - Log.i(TAG, "$transferred/$expected - $percentageStr - $infoText") val notification = Builder(context, CHANNEL_ID_OBSERVER).apply { setSmallIcon(R.drawable.ic_cloud_upload) setContentTitle(context.getString(R.string.notification_title)) - setContentText(percentageStr) + setContentText(appName) setOngoing(true) setShowWhen(false) setWhen(System.currentTimeMillis()) diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt index d253970a..ff298207 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt @@ -26,6 +26,7 @@ internal class NotificationBackupObserver( private val metadataManager: MetadataManager by inject() private var currentPackage: String? = null private var numPackages: Int = 0 + private var pmCounted: Boolean = false init { // Inform the notification manager that a backup has started @@ -93,13 +94,22 @@ internal class NotificationBackupObserver( ) currentPackage = packageName val appName = getAppName(packageName) - val app = if (appName != packageName) { - "${getAppName(packageName)} ($packageName)" + val name = if (appName != packageName) { + appName } else { - packageName + context.getString(R.string.backup_section_system) } - numPackages += 1 - nm.onBackupUpdate(app, numPackages, requestedPackages) + // prevent double counting of @pm@ which gets backed up with each requested chunk + if (packageName == MAGIC_PACKAGE_MANAGER) { + if (!pmCounted) { + numPackages += 1 + pmCounted = true + } + } else { + numPackages += 1 + } + Log.i(TAG, "$numPackages/$requestedPackages - $appName ($packageName)") + nm.onBackupUpdate(name, numPackages, requestedPackages) } private fun getAppName(packageId: String): CharSequence = getAppName(context, packageId) From 8da73ad8d1ceb7df44a0b00b20b7005768e73bef Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 20 Feb 2024 11:52:49 -0300 Subject: [PATCH 03/22] Make 'Backup now' action use AppBackupWorker --- .../seedvault/KoinInstrumentationTestApp.kt | 2 +- app/src/main/AndroidManifest.xml | 5 + .../java/com/stevesoltys/seedvault/App.kt | 4 +- .../com/stevesoltys/seedvault/BackupWorker.kt | 57 --------- .../seedvault/UsbIntentReceiver.kt | 6 +- .../seedvault/restore/RestoreViewModel.kt | 2 +- .../seedvault/settings/SettingsViewModel.kt | 9 +- .../stevesoltys/seedvault/storage/Services.kt | 4 +- .../ConfigurableBackupTransportService.kt | 25 ---- .../notification/BackupNotificationManager.kt | 87 ++++--------- .../NotificationBackupObserver.kt | 2 +- .../ui/storage/BackupStorageViewModel.kt | 6 +- .../seedvault/worker/AppBackupWorker.kt | 118 ++++++++++++++++++ .../backup => worker}/BackupRequester.kt | 5 +- .../seedvault/worker/WorkerModule.kt | 7 ++ app/src/main/res/values/strings.xml | 1 - 16 files changed, 172 insertions(+), 168 deletions(-) delete mode 100644 app/src/main/java/com/stevesoltys/seedvault/BackupWorker.kt create mode 100644 app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt rename app/src/main/java/com/stevesoltys/seedvault/{transport/backup => worker}/BackupRequester.kt (95%) diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt index c00438f2..6a2e5601 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt @@ -47,7 +47,7 @@ class KoinInstrumentationTestApp : App() { viewModel { currentBackupStorageViewModel = - spyk(BackupStorageViewModel(context, get(), get(), get(), get())) + spyk(BackupStorageViewModel(context, get(), get(), get())) currentBackupStorageViewModel!! } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6bc9213a..c3bd194c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -156,6 +156,11 @@ + + ( - repeatInterval = 24, - repeatIntervalTimeUnit = TimeUnit.HOURS, - flexTimeInterval = 2, - flexTimeIntervalUnit = TimeUnit.HOURS, - ).setConstraints(backupConstraints) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.HOURS) - .build() - val workManager = WorkManager.getInstance(appContext) - workManager.enqueueUniquePeriodicWork(UNIQUE_WORK_NAME, UPDATE, backupWorkRequest) - } - - fun unschedule(appContext: Context) { - val workManager = WorkManager.getInstance(appContext) - workManager.cancelUniqueWork(UNIQUE_WORK_NAME) - } - } - - override fun doWork(): Result { - // TODO once we make this the default, we should do storage backup here as well - // or have two workers and ensure they never run at the same time - return if (requestBackup(applicationContext)) Result.success() - else Result.retry() - } -} diff --git a/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt b/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt index 4800fcef..ff5208b0 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt @@ -20,8 +20,8 @@ import com.stevesoltys.seedvault.settings.FlashDrive import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.storage.StorageBackupService import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP -import com.stevesoltys.seedvault.transport.requestBackup import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE +import com.stevesoltys.seedvault.worker.AppBackupWorker import org.koin.core.context.GlobalContext.get import java.util.concurrent.TimeUnit.HOURS @@ -63,9 +63,7 @@ class UsbIntentReceiver : UsbMonitor() { i.putExtra(EXTRA_START_APP_BACKUP, true) startForegroundService(context, i) } else { - Thread { - requestBackup(context) - }.start() + AppBackupWorker.scheduleNow(context) } } 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 87d4b019..c7eaee3a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt @@ -39,7 +39,7 @@ import com.stevesoltys.seedvault.restore.install.isInstalled import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.storage.StorageRestoreService import com.stevesoltys.seedvault.transport.TRANSPORT_ID -import com.stevesoltys.seedvault.transport.backup.NUM_PACKAGES_PER_TRANSACTION +import com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator import com.stevesoltys.seedvault.ui.AppBackupState import com.stevesoltys.seedvault.ui.AppBackupState.FAILED diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index f56faa2f..6508aa7c 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -25,7 +25,6 @@ import androidx.lifecycle.liveData import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope import androidx.recyclerview.widget.DiffUtil.calculateDiff -import com.stevesoltys.seedvault.BackupWorker import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.metadata.MetadataManager @@ -33,9 +32,9 @@ import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.storage.StorageBackupJobService import com.stevesoltys.seedvault.storage.StorageBackupService import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP -import com.stevesoltys.seedvault.transport.requestBackup import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager +import com.stevesoltys.seedvault.worker.AppBackupWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -175,7 +174,7 @@ internal class SettingsViewModel( i.putExtra(EXTRA_START_APP_BACKUP, true) startForegroundService(app, i) } else { - requestBackup(app) + AppBackupWorker.scheduleNow(app) } } } @@ -267,9 +266,9 @@ internal class SettingsViewModel( fun onD2dChanged(enabled: Boolean) { backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), !enabled) if (enabled) { - BackupWorker.schedule(app) + AppBackupWorker.schedule(app) } else { - BackupWorker.unschedule(app) + AppBackupWorker.unschedule(app) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt index 1c54beb2..e42da2d3 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt @@ -1,7 +1,7 @@ package com.stevesoltys.seedvault.storage import android.content.Intent -import com.stevesoltys.seedvault.transport.requestBackup +import com.stevesoltys.seedvault.worker.AppBackupWorker import org.calyxos.backup.storage.api.BackupObserver import org.calyxos.backup.storage.api.RestoreObserver import org.calyxos.backup.storage.api.StorageBackup @@ -40,7 +40,7 @@ internal class StorageBackupService : BackupService() { override fun onBackupFinished(intent: Intent, success: Boolean) { if (intent.getBooleanExtra(EXTRA_START_APP_BACKUP, false)) { - requestBackup(applicationContext) + AppBackupWorker.scheduleNow(applicationContext) } } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt index 1b9fe3b6..9d81d3e5 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt @@ -2,18 +2,13 @@ package com.stevesoltys.seedvault.transport import android.app.Service import android.app.backup.IBackupManager -import android.content.Context import android.content.Intent import android.os.IBinder import android.util.Log -import androidx.annotation.WorkerThread import com.stevesoltys.seedvault.crypto.KeyManager -import com.stevesoltys.seedvault.transport.backup.BackupRequester -import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import org.koin.core.context.GlobalContext.get private val TAG = ConfigurableBackupTransportService::class.java.simpleName @@ -56,23 +51,3 @@ class ConfigurableBackupTransportService : Service(), KoinComponent { } } - -/** - * Requests the system to initiate a backup. - * - * @return true iff backups was requested successfully (backup itself can still fail). - */ -@WorkerThread -fun requestBackup(context: Context): Boolean { - val backupManager: IBackupManager = get().get() - return if (backupManager.isBackupEnabled) { - val packageService: PackageService = get().get() - - Log.d(TAG, "Backup is enabled, request backup...") - val backupRequester = BackupRequester(context, backupManager, packageService) - return backupRequester.requestBackup() - } else { - Log.i(TAG, "Backup is not enabled") - true // this counts as success - } -} diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt index 0a8bc09d..bc3fb55d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt @@ -30,17 +30,15 @@ import com.stevesoltys.seedvault.settings.SettingsActivity import kotlin.math.min private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver" -private const val CHANNEL_ID_APK = "NotificationApkBackup" private const val CHANNEL_ID_SUCCESS = "NotificationBackupSuccess" private const val CHANNEL_ID_ERROR = "NotificationError" private const val CHANNEL_ID_RESTORE_ERROR = "NotificationRestoreError" -private const val NOTIFICATION_ID_OBSERVER = 1 -internal const val NOTIFICATION_ID_APK = 2 -private const val NOTIFICATION_ID_SUCCESS = 3 -private const val NOTIFICATION_ID_ERROR = 4 -private const val NOTIFICATION_ID_RESTORE_ERROR = 5 -private const val NOTIFICATION_ID_BACKGROUND = 6 -private const val NOTIFICATION_ID_NO_MAIN_KEY_ERROR = 7 +internal const val NOTIFICATION_ID_OBSERVER = 1 +private const val NOTIFICATION_ID_SUCCESS = 2 +private const val NOTIFICATION_ID_ERROR = 3 +private const val NOTIFICATION_ID_RESTORE_ERROR = 4 +private const val NOTIFICATION_ID_BACKGROUND = 5 +private const val NOTIFICATION_ID_NO_MAIN_KEY_ERROR = 6 private val TAG = BackupNotificationManager::class.java.simpleName @@ -48,7 +46,6 @@ internal class BackupNotificationManager(private val context: Context) { private val nm = context.getSystemService(NotificationManager::class.java)!!.apply { createNotificationChannel(getObserverChannel()) - createNotificationChannel(getApkChannel()) createNotificationChannel(getSuccessChannel()) createNotificationChannel(getErrorChannel()) createNotificationChannel(getRestoreErrorChannel()) @@ -61,13 +58,6 @@ internal class BackupNotificationManager(private val context: Context) { } } - private fun getApkChannel(): NotificationChannel { - val title = context.getString(R.string.notification_apk_channel_title) - return NotificationChannel(CHANNEL_ID_APK, title, IMPORTANCE_LOW).apply { - enableVibration(false) - } - } - private fun getSuccessChannel(): NotificationChannel { val title = context.getString(R.string.notification_success_channel_title) return NotificationChannel(CHANNEL_ID_SUCCESS, title, IMPORTANCE_LOW).apply { @@ -91,8 +81,7 @@ internal class BackupNotificationManager(private val context: Context) { fun onApkBackup(packageName: String, name: CharSequence, transferred: Int, expected: Int) { Log.i(TAG, "$transferred/$expected - $name ($packageName)") val text = context.getString(R.string.notification_apk_text, name) - val notification = getApkBackupNotification(text, transferred, expected) - nm.notify(NOTIFICATION_ID_APK, notification) + updateBackupNotification(text, transferred, expected) } /** @@ -100,32 +89,15 @@ internal class BackupNotificationManager(private val context: Context) { */ fun onAppsNotBackedUp() { Log.i(TAG, "onAppsNotBackedUp") - val notification = - getApkBackupNotification(context.getString(R.string.notification_apk_not_backed_up)) - nm.notify(NOTIFICATION_ID_APK, notification) + val text = context.getString(R.string.notification_apk_not_backed_up) + updateBackupNotification(text) } - fun getApkBackupNotification( - text: String?, - expected: Int = 0, - transferred: Int = 0, - ): Notification = Builder(context, CHANNEL_ID_APK).apply { - setSmallIcon(R.drawable.ic_cloud_upload) - setContentTitle(context.getString(R.string.notification_title)) - setContentText(text) - setOngoing(true) - setShowWhen(false) - setWhen(System.currentTimeMillis()) - setProgress(expected, transferred, false) - priority = PRIORITY_DEFAULT - foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE - }.build() - /** * Call after [onApkBackup] or [onAppsNotBackedUp] were called. */ fun onApkBackupDone() { - nm.cancel(NOTIFICATION_ID_APK) + nm.cancel(NOTIFICATION_ID_OBSERVER) } /** @@ -133,7 +105,7 @@ internal class BackupNotificationManager(private val context: Context) { */ fun onBackupStarted(expectedPackages: Int) { updateBackupNotification( - appName = "", // This passes quickly, no need to show something here + text = "", // This passes quickly, no need to show something here transferred = 0, expected = expectedPackages ) @@ -145,44 +117,29 @@ internal class BackupNotificationManager(private val context: Context) { * this type is is expected to get called after [onApkBackup]. */ fun onBackupUpdate(app: CharSequence, transferred: Int, total: Int) { - updateBackupNotification( - appName = app, - transferred = min(transferred, total), - expected = total - ) + updateBackupNotification(app, min(transferred, total), total) } private fun updateBackupNotification( - appName: CharSequence, - transferred: Int, - expected: Int, + text: CharSequence, + transferred: Int = 0, + expected: Int = 0, ) { - val notification = Builder(context, CHANNEL_ID_OBSERVER).apply { - setSmallIcon(R.drawable.ic_cloud_upload) - setContentTitle(context.getString(R.string.notification_title)) - setContentText(appName) - setOngoing(true) - setShowWhen(false) - setWhen(System.currentTimeMillis()) - setProgress(expected, transferred, false) - priority = PRIORITY_DEFAULT - foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE - }.build() + val notification = getBackupNotification(text, transferred, expected) nm.notify(NOTIFICATION_ID_OBSERVER, notification) } - private fun updateBackgroundBackupNotification(infoText: CharSequence) { - Log.i(TAG, "$infoText") - val notification = Builder(context, CHANNEL_ID_OBSERVER).apply { + fun getBackupNotification(text: CharSequence, progress: Int = 0, total: Int = 0): Notification { + return Builder(context, CHANNEL_ID_OBSERVER).apply { setSmallIcon(R.drawable.ic_cloud_upload) setContentTitle(context.getString(R.string.notification_title)) + setContentText(text) setOngoing(true) setShowWhen(false) - setWhen(System.currentTimeMillis()) - setProgress(0, 0, true) - priority = PRIORITY_LOW + setProgress(total, progress, progress == 0 && total == 0) + priority = PRIORITY_DEFAULT + foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE }.build() - nm.notify(NOTIFICATION_ID_BACKGROUND, notification) } fun onServiceDestroyed() { diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt index ff298207..5f5eaea4 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt @@ -10,7 +10,7 @@ import android.util.Log.isLoggable import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.metadata.MetadataManager -import com.stevesoltys.seedvault.transport.backup.BackupRequester +import com.stevesoltys.seedvault.worker.BackupRequester import org.koin.core.component.KoinComponent import org.koin.core.component.inject diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt index 4595468b..33fe51b8 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt @@ -12,8 +12,7 @@ import androidx.lifecycle.viewModelScope import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.transport.TRANSPORT_ID -import com.stevesoltys.seedvault.transport.backup.BackupCoordinator -import com.stevesoltys.seedvault.transport.requestBackup +import com.stevesoltys.seedvault.worker.AppBackupWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.calyxos.backup.storage.api.StorageBackup @@ -24,7 +23,6 @@ private val TAG = BackupStorageViewModel::class.java.simpleName internal class BackupStorageViewModel( private val app: Application, private val backupManager: IBackupManager, - private val backupCoordinator: BackupCoordinator, private val storageBackup: StorageBackup, settingsManager: SettingsManager, ) : StorageViewModel(app, settingsManager) { @@ -73,7 +71,7 @@ internal class BackupStorageViewModel( // notify the UI that the location has been set mLocationChecked.postEvent(LocationResult()) if (requestBackup) { - requestBackup(app) + AppBackupWorker.scheduleNow(app) } } else { // notify the UI that the location was invalid diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt new file mode 100644 index 00000000..f6cc1b45 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.worker + +import android.content.Context +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE +import androidx.work.ExistingPeriodicWorkPolicy.UPDATE +import androidx.work.ExistingWorkPolicy.REPLACE +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager +import com.stevesoltys.seedvault.ui.notification.NOTIFICATION_ID_OBSERVER +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.util.concurrent.TimeUnit + +class AppBackupWorker( + appContext: Context, + workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams), KoinComponent { + + companion object { + private val TAG = AppBackupWorker::class.simpleName + private const val UNIQUE_WORK_NAME = "com.stevesoltys.seedvault.APP_BACKUP" + private const val TAG_NOW = "com.stevesoltys.seedvault.TAG_NOW" + + fun schedule(context: Context, existingWorkPolicy: ExistingPeriodicWorkPolicy = UPDATE) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .setRequiresCharging(true) + .build() + val workRequest = PeriodicWorkRequestBuilder( + repeatInterval = 24, + repeatIntervalTimeUnit = TimeUnit.HOURS, + flexTimeInterval = 2, + flexTimeIntervalUnit = TimeUnit.HOURS, + ).setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.MINUTES) + .build() + val workManager = WorkManager.getInstance(context) + Log.i(TAG, "Scheduling app backup: $workRequest") + workManager.enqueueUniquePeriodicWork(UNIQUE_WORK_NAME, existingWorkPolicy, workRequest) + } + + fun scheduleNow(context: Context) { + val workRequest = OneTimeWorkRequestBuilder() + .setExpedited(RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .addTag(TAG_NOW) + .build() + val workManager = WorkManager.getInstance(context) + Log.i(TAG, "Asking to do app backup now...") + workManager.enqueueUniqueWork(UNIQUE_WORK_NAME, REPLACE, workRequest) + } + + fun unschedule(context: Context) { + Log.i(TAG, "Unscheduling app backup...") + val workManager = WorkManager.getInstance(context) + workManager.cancelUniqueWork(UNIQUE_WORK_NAME) + } + } + + private val backupRequester: BackupRequester by inject() + private val apkBackupManager: ApkBackupManager by inject() + private val nm: BackupNotificationManager by inject() + + override suspend fun doWork(): Result { + try { + setForeground(createForegroundInfo()) + } catch (e: Exception) { + Log.e(TAG, "Error while running setForeground: ", e) + } + var result: Result = Result.success() + try { + Log.i(TAG, "Starting APK backup...") + apkBackupManager.backup() + } catch (e: Exception) { + Log.e(TAG, "Error backing up APKs: ", e) + result = Result.retry() + } finally { + Log.i(TAG, "Requesting app data backup...") + val requestSuccess = try { + if (backupRequester.isBackupEnabled) { + Log.d(TAG, "Backup is enabled, request backup...") + backupRequester.requestBackup() + } else true + } finally { + // schedule next backup, because the old one gets lost + // when scheduling a OneTimeWorkRequest with the same unique name via scheduleNow() + if (tags.contains(TAG_NOW)) { + // needs to use CANCEL_AND_REENQUEUE otherwise it doesn't get scheduled + schedule(applicationContext, CANCEL_AND_REENQUEUE) + } + } + if (!requestSuccess) result = Result.retry() + } + return result + } + + private fun createForegroundInfo() = ForegroundInfo( + NOTIFICATION_ID_OBSERVER, + nm.getBackupNotification(""), + FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupRequester.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/BackupRequester.kt similarity index 95% rename from app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupRequester.kt rename to app/src/main/java/com/stevesoltys/seedvault/worker/BackupRequester.kt index 209db762..9eac7406 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupRequester.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/BackupRequester.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package com.stevesoltys.seedvault.transport.backup +package com.stevesoltys.seedvault.worker import android.app.backup.BackupManager import android.app.backup.IBackupManager @@ -12,6 +12,7 @@ import android.os.RemoteException import android.util.Log import androidx.annotation.WorkerThread import com.stevesoltys.seedvault.BackupMonitor +import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.NotificationBackupObserver import org.koin.core.component.KoinComponent @@ -34,6 +35,8 @@ internal class BackupRequester( val packageService: PackageService, ) : KoinComponent { + val isBackupEnabled: Boolean get() = backupManager.isBackupEnabled + private val packages = packageService.eligiblePackages private val observer = NotificationBackupObserver( context = context, diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt index ed18a635..dce45be2 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt @@ -9,6 +9,13 @@ import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val workerModule = module { + factory { + BackupRequester( + context = androidContext(), + backupManager = get(), + packageService = get(), + ) + } single { ApkBackup( pm = androidContext().packageManager, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8283aaf..44097e71 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -119,7 +119,6 @@ Backup notification - APK backup notification Success notification Backup running Backing up APK of %s From 911a8dabf4e3625321671d19c585cdd64d0c760c Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 21 Feb 2024 14:53:15 -0300 Subject: [PATCH 04/22] Expose time of next backup in UI to help debugging scheduling issues --- .../seedvault/settings/SettingsFragment.kt | 49 +++++++++++++++++-- .../seedvault/settings/SettingsViewModel.kt | 9 ++++ .../seedvault/worker/AppBackupWorker.kt | 2 +- app/src/main/res/values/strings.xml | 2 + 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt index 8115c533..466107c0 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt @@ -140,7 +140,10 @@ class SettingsFragment : PreferenceFragmentCompat() { super.onViewCreated(view, savedInstanceState) viewModel.lastBackupTime.observe(viewLifecycleOwner) { time -> - setAppBackupStatusSummary(time) + setAppBackupStatusSummary(time, viewModel.nextScheduleTimeMillis.value) + } + viewModel.nextScheduleTimeMillis.observe(viewLifecycleOwner) { time -> + setAppBackupStatusSummary(viewModel.lastBackupTime.value, time) } val backupFiles: Preference = findPreference("backup_files")!! @@ -159,6 +162,10 @@ class SettingsFragment : PreferenceFragmentCompat() { setBackupEnabledState() setBackupLocationSummary() setAutoRestoreState() + setAppBackupStatusSummary( + lastBackupInMillis = viewModel.lastBackupTime.value, + nextScheduleTimeMillis = viewModel.nextScheduleTimeMillis.value, + ) } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { @@ -244,10 +251,42 @@ class SettingsFragment : PreferenceFragmentCompat() { backupLocation.summary = storage?.name ?: getString(R.string.settings_backup_location_none) } - private fun setAppBackupStatusSummary(lastBackupInMillis: Long) { - // set time of last backup - val lastBackup = lastBackupInMillis.toRelativeTime(requireContext()) - backupStatus.summary = getString(R.string.settings_backup_status_summary, lastBackup) + private fun setAppBackupStatusSummary( + lastBackupInMillis: Long?, + nextScheduleTimeMillis: Long?, + ) { + val sb = StringBuilder() + if (lastBackupInMillis != null) { + // set time of last backup + val lastBackup = lastBackupInMillis.toRelativeTime(requireContext()) + sb.append(getString(R.string.settings_backup_status_summary, lastBackup)) + } + if (nextScheduleTimeMillis != null) { + // insert linebreak, if we have text before + if (sb.isNotEmpty()) sb.append("\n") + // set time of next backup + when (nextScheduleTimeMillis) { + -1L -> { + val text = getString(R.string.settings_backup_last_backup_never) + sb.append(getString(R.string.settings_backup_status_next_backup, text)) + } + + Long.MAX_VALUE -> { + val text = if (backupManager.isBackupEnabled) { + getString(R.string.notification_title) + } else { + getString(R.string.settings_backup_last_backup_never) + } + sb.append(getString(R.string.settings_backup_status_next_backup, text)) + } + + else -> { + val text = nextScheduleTimeMillis.toRelativeTime(requireContext()) + sb.append(getString(R.string.settings_backup_status_next_backup_estimate, text)) + } + } + } + backupStatus.summary = sb.toString() } private fun onEnablingStorageBackup() { diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index 6508aa7c..d241e919 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -22,9 +22,11 @@ import androidx.core.content.ContextCompat.startForegroundService import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.liveData +import androidx.lifecycle.map import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope import androidx.recyclerview.widget.DiffUtil.calculateDiff +import androidx.work.WorkManager import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.metadata.MetadataManager @@ -35,6 +37,7 @@ import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_ST import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.worker.AppBackupWorker +import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -61,6 +64,7 @@ internal class SettingsViewModel( private val contentResolver = app.contentResolver private val connectivityManager: ConnectivityManager? = app.getSystemService(ConnectivityManager::class.java) + private val workManager = WorkManager.getInstance(app) override val isRestoreOperation = false @@ -68,6 +72,11 @@ internal class SettingsViewModel( val backupPossible: LiveData = mBackupPossible internal val lastBackupTime = metadataManager.lastBackupTime + val nextScheduleTimeMillis = + workManager.getWorkInfosForUniqueWorkLiveData(UNIQUE_WORK_NAME).map { + if (it.size > 0) it[0].nextScheduleTimeMillis + else -1L + } private val mAppStatusList = lastBackupTime.switchMap { // updates app list when lastBackupTime changes diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt index f6cc1b45..43f53d95 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt @@ -35,7 +35,7 @@ class AppBackupWorker( companion object { private val TAG = AppBackupWorker::class.simpleName - private const val UNIQUE_WORK_NAME = "com.stevesoltys.seedvault.APP_BACKUP" + internal const val UNIQUE_WORK_NAME = "com.stevesoltys.seedvault.APP_BACKUP" private const val TAG_NOW = "com.stevesoltys.seedvault.TAG_NOW" fun schedule(context: Context, existingWorkPolicy: ExistingPeriodicWorkPolicy = UPDATE) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 44097e71..13c09436 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,6 +30,8 @@ Disable app backup Backup status Last backup: %1$s + Next backup: %1$s + Next backup (estimate): %1$s Exclude apps Backup now Storage backup (beta) From 04fc90e9f70140ea7e3f314fb3f1070b525f9555 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 21 Feb 2024 17:08:35 -0300 Subject: [PATCH 05/22] Migrate to own backup scheduling --- .../java/com/stevesoltys/seedvault/App.kt | 25 +++++++ .../settings/ExpertSettingsFragment.kt | 3 +- .../seedvault/settings/SettingsFragment.kt | 14 +++- .../seedvault/settings/SettingsViewModel.kt | 68 ++++++++++--------- .../seedvault/worker/AppBackupWorker.kt | 30 ++++---- .../java/com/stevesoltys/seedvault/TestApp.kt | 2 + 6 files changed, 91 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index c65dc9f7..af6ac99a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/App.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt @@ -9,7 +9,10 @@ import android.content.pm.PackageManager.PERMISSION_GRANTED import android.os.Build import android.os.ServiceManager.getService import android.os.StrictMode +import android.os.UserHandle import android.os.UserManager +import android.provider.Settings +import androidx.work.WorkManager import com.stevesoltys.seedvault.crypto.cryptoModule import com.stevesoltys.seedvault.header.headerModule import com.stevesoltys.seedvault.metadata.MetadataManager @@ -28,6 +31,7 @@ import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel +import com.stevesoltys.seedvault.worker.AppBackupWorker import com.stevesoltys.seedvault.worker.workerModule import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext @@ -43,6 +47,8 @@ import org.koin.dsl.module */ open class App : Application() { + open val isTest: Boolean = false + private val appModule = module { single { SettingsManager(this@App) } single { BackupNotificationManager(this@App) } @@ -79,6 +85,7 @@ open class App : Application() { permitDiskReads { migrateTokenFromMetadataToSettingsManager() } + if (!isTest) migrateToOwnScheduling() } protected open fun startKoin() = startKoin { @@ -102,6 +109,7 @@ open class App : Application() { private val settingsManager: SettingsManager by inject() private val metadataManager: MetadataManager by inject() + private val backupManager: IBackupManager by inject() /** * The responsibility for the current token was moved to the [SettingsManager] @@ -117,6 +125,23 @@ open class App : Application() { } } + /** + * Disables the framework scheduling in favor of our own. + * Introduced in the first half of 2024 and can be removed after a suitable migration period. + */ + protected open fun migrateToOwnScheduling() { + if (!isFrameworkSchedulingEnabled()) return // already on own scheduling + + backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), false) + if (backupManager.isBackupEnabled) AppBackupWorker.schedule(applicationContext) + // cancel old D2D worker + WorkManager.getInstance(this).cancelUniqueWork("APP_BACKUP") + } + + private fun isFrameworkSchedulingEnabled(): Boolean = Settings.Secure.getInt( + contentResolver, Settings.Secure.BACKUP_SCHEDULING_ENABLED, 1 + ) == 1 // 1 means enabled which is the default + } const val MAGIC_PACKAGE_MANAGER = PACKAGE_MANAGER_SENTINEL diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt index b1281325..c7e7d378 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt @@ -44,8 +44,7 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() { val d2dPreference = findPreference(PREF_KEY_D2D_BACKUPS) d2dPreference?.setOnPreferenceChangeListener { _, newValue -> - viewModel.onD2dChanged(newValue as Boolean) - d2dPreference.isChecked = newValue + d2dPreference.isChecked = newValue as Boolean // automatically enable unlimited quota when enabling D2D backups if (d2dPreference.isChecked) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt index 466107c0..08648a8e 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt @@ -23,6 +23,7 @@ import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.restore.RestoreActivity import com.stevesoltys.seedvault.ui.toRelativeTime +import com.stevesoltys.seedvault.worker.AppBackupWorker import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.sharedViewModel @@ -125,8 +126,9 @@ class SettingsFragment : PreferenceFragmentCompat() { backupStorage = findPreference("backup_storage")!! backupStorage.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue -> val disable = !(newValue as Boolean) + // TODO this should really get moved out off the UI layer if (disable) { - viewModel.disableStorageBackup() + viewModel.cancelBackupWorkers() return@OnPreferenceChangeListener true } onEnablingStorageBackup() @@ -208,10 +210,16 @@ class SettingsFragment : PreferenceFragmentCompat() { else -> super.onOptionsItemSelected(item) } + // TODO this should really get moved out off the UI layer private fun trySetBackupEnabled(enabled: Boolean): Boolean { return try { backupManager.isBackupEnabled = enabled - if (enabled) viewModel.enableCallLogBackup() + if (enabled) { + AppBackupWorker.schedule(requireContext()) + viewModel.enableCallLogBackup() + } else { + AppBackupWorker.unschedule(requireContext()) + } backup.isChecked = enabled true } catch (e: RemoteException) { @@ -307,7 +315,7 @@ class SettingsFragment : PreferenceFragmentCompat() { LENGTH_LONG ).show() } - viewModel.enableStorageBackup() + viewModel.scheduleBackupWorkers() backupStorage.isChecked = true dialog.dismiss() } diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index d241e919..eda7c4f7 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -12,7 +12,6 @@ import android.net.NetworkCapabilities import android.net.NetworkRequest import android.net.Uri import android.os.Process.myUid -import android.os.UserHandle import android.provider.Settings import android.util.Log import android.widget.Toast @@ -92,19 +91,19 @@ internal class SettingsViewModel( private val storageObserver = object : ContentObserver(null) { override fun onChange(selfChange: Boolean, uris: MutableCollection, flags: Int) { - onStorageLocationChanged() + onStoragePropertiesChanged() } } private inner class NetworkObserver : ConnectivityManager.NetworkCallback() { var registered = false override fun onAvailable(network: Network) { - onStorageLocationChanged() + onStoragePropertiesChanged() } override fun onLost(network: Network) { super.onLost(network) - onStorageLocationChanged() + onStoragePropertiesChanged() } } @@ -119,13 +118,29 @@ internal class SettingsViewModel( // ensures the lastBackupTime LiveData gets set metadataManager.getLastBackupTime() } - onStorageLocationChanged() + onStoragePropertiesChanged() loadFilesSummary() } override fun onStorageLocationChanged() { val storage = settingsManager.getStorage() ?: return + Log.i(TAG, "onStorageLocationChanged") + if (storage.isUsb) { + // disable storage backup if new storage is on USB + cancelBackupWorkers() + } else { + // enable it, just in case the previous storage was on USB, + // also to update the network requirement of the new storage + scheduleBackupWorkers() + } + onStoragePropertiesChanged() + } + + private fun onStoragePropertiesChanged() { + val storage = settingsManager.getStorage() ?: return + + Log.d(TAG, "onStoragePropertiesChanged") // register storage observer try { contentResolver.unregisterContentObserver(storageObserver) @@ -148,14 +163,6 @@ internal class SettingsViewModel( networkCallback.registered = true } - if (settingsManager.isStorageBackupEnabled()) { - // disable storage backup if new storage is on USB - if (storage.isUsb) disableStorageBackup() - // enable it, just in case the previous storage was on USB, - // also to update the network requirement of the new storage - else enableStorageBackup() - } - viewModelScope.launch(Dispatchers.IO) { val canDo = settingsManager.canDoBackupNow() mBackupPossible.postValue(canDo) @@ -231,20 +238,24 @@ internal class SettingsViewModel( return keyManager.hasMainKey() } - fun enableStorageBackup() { + fun scheduleBackupWorkers() { val storage = settingsManager.getStorage() ?: error("no storage available") - if (!storage.isUsb) BackupJobService.scheduleJob( - context = app, - jobServiceClass = StorageBackupJobService::class.java, - periodMillis = HOURS.toMillis(24), - networkType = if (storage.requiresNetwork) NETWORK_TYPE_UNMETERED - else NETWORK_TYPE_NONE, - deviceIdle = false, - charging = true - ) + if (!storage.isUsb) { + if (backupManager.isBackupEnabled) AppBackupWorker.schedule(app) + if (settingsManager.isStorageBackupEnabled()) BackupJobService.scheduleJob( + context = app, + jobServiceClass = StorageBackupJobService::class.java, + periodMillis = HOURS.toMillis(24), + networkType = if (storage.requiresNetwork) NETWORK_TYPE_UNMETERED + else NETWORK_TYPE_NONE, + deviceIdle = false, + charging = true + ) + } } - fun disableStorageBackup() { + fun cancelBackupWorkers() { + AppBackupWorker.unschedule(app) BackupJobService.cancelJob(app) } @@ -272,13 +283,4 @@ internal class SettingsViewModel( Toast.makeText(app, str, LENGTH_LONG).show() } - fun onD2dChanged(enabled: Boolean) { - backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), !enabled) - if (enabled) { - AppBackupWorker.schedule(app) - } else { - AppBackupWorker.unschedule(app) - } - } - } diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt index 43f53d95..96ac383f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt @@ -83,6 +83,19 @@ class AppBackupWorker( } catch (e: Exception) { Log.e(TAG, "Error while running setForeground: ", e) } + return try { + doBackup() + } finally { + // schedule next backup, because the old one gets lost + // when scheduling a OneTimeWorkRequest with the same unique name via scheduleNow() + if (tags.contains(TAG_NOW) && backupRequester.isBackupEnabled) { + // needs to use CANCEL_AND_REENQUEUE otherwise it doesn't get scheduled + schedule(applicationContext, CANCEL_AND_REENQUEUE) + } + } + } + + private suspend fun doBackup(): Result { var result: Result = Result.success() try { Log.i(TAG, "Starting APK backup...") @@ -92,19 +105,10 @@ class AppBackupWorker( result = Result.retry() } finally { Log.i(TAG, "Requesting app data backup...") - val requestSuccess = try { - if (backupRequester.isBackupEnabled) { - Log.d(TAG, "Backup is enabled, request backup...") - backupRequester.requestBackup() - } else true - } finally { - // schedule next backup, because the old one gets lost - // when scheduling a OneTimeWorkRequest with the same unique name via scheduleNow() - if (tags.contains(TAG_NOW)) { - // needs to use CANCEL_AND_REENQUEUE otherwise it doesn't get scheduled - schedule(applicationContext, CANCEL_AND_REENQUEUE) - } - } + val requestSuccess = if (backupRequester.isBackupEnabled) { + Log.d(TAG, "Backup is enabled, request backup...") + backupRequester.requestBackup() + } else true if (!requestSuccess) result = Result.retry() } return result diff --git a/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt b/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt index cdf03aea..41d6e53b 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt @@ -19,6 +19,8 @@ import org.koin.dsl.module class TestApp : App() { + override val isTest: Boolean = true + private val testCryptoModule = module { factory { CipherFactoryImpl(get()) } single { KeyManagerTestImpl() } From 0d7156789e9abe6aa9e675e07a40a92d34e4049d Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 21 Feb 2024 17:25:13 -0300 Subject: [PATCH 06/22] Guard against BadParcelableException when getting app list hopefully something rare, but it just happened to me while testing. It seems it happens when there are many apps installed (>500) and the app list is open while a backup happens. Then, we keep reloading the list and hammer the package manager hard which it seems can't handle it. It does recover on its own though. --- .../stevesoltys/seedvault/settings/SettingsViewModel.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index eda7c4f7..1fefc982 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -11,6 +11,7 @@ import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.net.Uri +import android.os.BadParcelableException import android.os.Process.myUid import android.provider.Settings import android.util.Log @@ -196,7 +197,13 @@ internal class SettingsViewModel( } private fun getAppStatusResult(): LiveData = liveData(Dispatchers.Default) { - val list = appListRetriever.getAppList() + val list = try { + Log.i(TAG, "Loading app list...") + appListRetriever.getAppList() + } catch (e: BadParcelableException) { + Log.e(TAG, "Error getting app list: ", e) + emptyList() + } val oldList = mAppStatusList.value?.appStatusList ?: emptyList() val diff = calculateDiff(AppStatusDiff(oldList, list)) emit(AppStatusResult(list, diff)) From 6e7bc89e2f98a6e8487861c3abb8c12605336de4 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 22 Feb 2024 09:15:16 -0300 Subject: [PATCH 07/22] Respect when worker was stopped and log worker ID as well as object, because we've seen two scheduled workers running at the same time, requesting a backup at the same time. This should not happen. --- .../seedvault/worker/AppBackupWorker.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt index 96ac383f..32c44a24 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt @@ -78,13 +78,18 @@ class AppBackupWorker( private val nm: BackupNotificationManager by inject() override suspend fun doWork(): Result { + Log.i(TAG, "Start worker $this ($id)") try { setForeground(createForegroundInfo()) } catch (e: Exception) { Log.e(TAG, "Error while running setForeground: ", e) } return try { - doBackup() + if (isStopped) { + Result.retry() + } else { + doBackup() + } } finally { // schedule next backup, because the old one gets lost // when scheduling a OneTimeWorkRequest with the same unique name via scheduleNow() @@ -98,17 +103,18 @@ class AppBackupWorker( private suspend fun doBackup(): Result { var result: Result = Result.success() try { - Log.i(TAG, "Starting APK backup...") - apkBackupManager.backup() + Log.i(TAG, "Starting APK backup... (stopped: $isStopped)") + if (!isStopped) apkBackupManager.backup() } catch (e: Exception) { Log.e(TAG, "Error backing up APKs: ", e) result = Result.retry() } finally { - Log.i(TAG, "Requesting app data backup...") - val requestSuccess = if (backupRequester.isBackupEnabled) { + Log.i(TAG, "Requesting app data backup... (stopped: $isStopped)") + val requestSuccess = if (!isStopped && backupRequester.isBackupEnabled) { Log.d(TAG, "Backup is enabled, request backup...") backupRequester.requestBackup() } else true + Log.d(TAG, "Have requested backup.") if (!requestSuccess) result = Result.retry() } return result From e615402458a0c77b11d465d3f72dcbc76d2424bf Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 22 Feb 2024 09:41:53 -0300 Subject: [PATCH 08/22] During restore, show apps without APK as failed Previously, we backed up APKs of apps we could not back up (even if APK backup was disabled) so the user had a chance to get at least the apps back when restoring. Now, it is enough to record metadata about the app and the user will be able to manually install the app. The install apps step won't be skipped anymore. --- .../seedvault/restore/install/ApkRestore.kt | 14 ++++++++------ .../seedvault/worker/BackupRequester.kt | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt index 4b6ea82d..36326e0a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt @@ -38,14 +38,12 @@ internal class ApkRestore( private val pm = context.packageManager - @Suppress("BlockingMethodInNonBlockingContext") fun restore(backup: RestorableBackup) = flow { - // filter out packages without APK and get total + // we don't filter out apps without APK, so the user can manually install them val packages = backup.packageMetadataMap.filter { - // We also need to exclude the DocumentsProvider used to retrieve backup data. + // We need to exclude the DocumentsProvider used to retrieve backup data. // Otherwise, it gets killed when we install it, terminating our restoration. - val isStorageProvider = it.key == storagePlugin.providerPackageName - it.value.hasApk() && !isStorageProvider + it.key != storagePlugin.providerPackageName } val total = packages.size var progress = 0 @@ -66,7 +64,11 @@ internal class ApkRestore( // re-install individual packages and emit updates for ((packageName, metadata) in packages) { try { - restore(this, backup, packageName, metadata, installResult) + if (metadata.hasApk()) { + restore(this, backup, packageName, metadata, installResult) + } else { + emit(installResult.fail(packageName)) + } } catch (e: IOException) { Log.e(TAG, "Error re-installing APK for $packageName.", e) emit(installResult.fail(packageName)) diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/BackupRequester.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/BackupRequester.kt index 9eac7406..02d4cc3b 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/BackupRequester.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/BackupRequester.kt @@ -102,7 +102,7 @@ internal class BackupRequester( (packageIndex + NUM_PACKAGES_PER_TRANSACTION).coerceAtMost(packages.size) val packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray() val numBackingUp = packageIndex + packageChunk.size - Log.i(TAG, "Requesting backup for $numBackingUp/${packages.size} packages...") + Log.i(TAG, "Requesting backup for $numBackingUp of ${packages.size} packages...") packageIndex += packageChunk.size return packageChunk } From 0c1898c198f428362564109a9ae56f8036ec1e35 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 22 Feb 2024 11:37:16 -0300 Subject: [PATCH 09/22] Properly schedule/cancel backup workers when backup destination changes When the user changes to USB storage, we need to cancel current schedulings, because the storage is not always available (maybe can use a trigger URI?). And if moving to a non-USB storage, we need to schedule backups again. Unfortunately, there are two places in the code where we handle storage location changes. Ideally, those get unified at some point. --- .../seedvault/settings/SettingsFragment.kt | 11 +++--- .../seedvault/settings/SettingsViewModel.kt | 23 ++++++++---- .../ui/recoverycode/RecoveryCodeViewModel.kt | 1 + .../ui/storage/BackupStorageViewModel.kt | 35 +++++++++++++++++++ 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt index 08648a8e..559a9c44 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt @@ -23,7 +23,6 @@ import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.restore.RestoreActivity import com.stevesoltys.seedvault.ui.toRelativeTime -import com.stevesoltys.seedvault.worker.AppBackupWorker import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.sharedViewModel @@ -128,7 +127,7 @@ class SettingsFragment : PreferenceFragmentCompat() { val disable = !(newValue as Boolean) // TODO this should really get moved out off the UI layer if (disable) { - viewModel.cancelBackupWorkers() + viewModel.cancelFilesBackup() return@OnPreferenceChangeListener true } onEnablingStorageBackup() @@ -215,10 +214,10 @@ class SettingsFragment : PreferenceFragmentCompat() { return try { backupManager.isBackupEnabled = enabled if (enabled) { - AppBackupWorker.schedule(requireContext()) + viewModel.scheduleAppBackup() viewModel.enableCallLogBackup() } else { - AppBackupWorker.unschedule(requireContext()) + viewModel.cancelAppBackup() } backup.isChecked = enabled true @@ -280,7 +279,7 @@ class SettingsFragment : PreferenceFragmentCompat() { } Long.MAX_VALUE -> { - val text = if (backupManager.isBackupEnabled) { + val text = if (backupManager.isBackupEnabled && storage?.isUsb != true) { getString(R.string.notification_title) } else { getString(R.string.settings_backup_last_backup_never) @@ -315,7 +314,7 @@ class SettingsFragment : PreferenceFragmentCompat() { LENGTH_LONG ).show() } - viewModel.scheduleBackupWorkers() + viewModel.scheduleFilesBackup() backupStorage.isChecked = true dialog.dismiss() } diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index 1fefc982..c0aedf7b 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -129,11 +129,13 @@ internal class SettingsViewModel( Log.i(TAG, "onStorageLocationChanged") if (storage.isUsb) { // disable storage backup if new storage is on USB - cancelBackupWorkers() + cancelAppBackup() + cancelFilesBackup() } else { // enable it, just in case the previous storage was on USB, // also to update the network requirement of the new storage - scheduleBackupWorkers() + scheduleAppBackup() + scheduleFilesBackup() } onStoragePropertiesChanged() } @@ -245,11 +247,15 @@ internal class SettingsViewModel( return keyManager.hasMainKey() } - fun scheduleBackupWorkers() { + fun scheduleAppBackup() { val storage = settingsManager.getStorage() ?: error("no storage available") - if (!storage.isUsb) { - if (backupManager.isBackupEnabled) AppBackupWorker.schedule(app) - if (settingsManager.isStorageBackupEnabled()) BackupJobService.scheduleJob( + if (!storage.isUsb && backupManager.isBackupEnabled) AppBackupWorker.schedule(app) + } + + fun scheduleFilesBackup() { + val storage = settingsManager.getStorage() ?: error("no storage available") + if (!storage.isUsb && settingsManager.isStorageBackupEnabled()) { + BackupJobService.scheduleJob( context = app, jobServiceClass = StorageBackupJobService::class.java, periodMillis = HOURS.toMillis(24), @@ -261,8 +267,11 @@ internal class SettingsViewModel( } } - fun cancelBackupWorkers() { + fun cancelAppBackup() { AppBackupWorker.unschedule(app) + } + + fun cancelFilesBackup() { BackupJobService.cancelJob(app) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt index 5dbc82ef..ba126ab1 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt @@ -102,6 +102,7 @@ internal class RecoveryCodeViewModel( */ fun reinitializeBackupLocation() { Log.d(TAG, "Re-initializing backup location...") + // TODO this code is almost identical to BackupStorageViewModel#onLocationSet(), unify? GlobalScope.launch(Dispatchers.IO) { // remove old storage snapshots and clear cache storageBackup.deleteAllSnapshots() diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt index 33fe51b8..01cc817f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import android.app.backup.BackupProgress import android.app.backup.IBackupManager import android.app.backup.IBackupObserver +import android.app.job.JobInfo import android.net.Uri import android.os.UserHandle import android.util.Log @@ -11,12 +12,15 @@ import androidx.annotation.WorkerThread import androidx.lifecycle.viewModelScope import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.settings.SettingsManager +import com.stevesoltys.seedvault.storage.StorageBackupJobService import com.stevesoltys.seedvault.transport.TRANSPORT_ID import com.stevesoltys.seedvault.worker.AppBackupWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.calyxos.backup.storage.api.StorageBackup +import org.calyxos.backup.storage.backup.BackupJobService import java.io.IOException +import java.util.concurrent.TimeUnit private val TAG = BackupStorageViewModel::class.java.simpleName @@ -31,8 +35,18 @@ internal class BackupStorageViewModel( override fun onLocationSet(uri: Uri) { val isUsb = saveStorage(uri) + if (isUsb) { + // disable storage backup if new storage is on USB + cancelBackupWorkers() + } else { + // enable it, just in case the previous storage was on USB, + // also to update the network requirement of the new storage + scheduleBackupWorkers() + } viewModelScope.launch(Dispatchers.IO) { // remove old storage snapshots and clear cache + // TODO is this needed? It also does create all 255 chunk folders which takes time + // pass a flag to getCurrentBackupSnapshots() to not create missing folders? storageBackup.deleteAllSnapshots() storageBackup.clearCache() try { @@ -52,6 +66,27 @@ internal class BackupStorageViewModel( } } + private fun scheduleBackupWorkers() { + val storage = settingsManager.getStorage() ?: error("no storage available") + if (!storage.isUsb) { + if (backupManager.isBackupEnabled) AppBackupWorker.schedule(app) + if (settingsManager.isStorageBackupEnabled()) BackupJobService.scheduleJob( + context = app, + jobServiceClass = StorageBackupJobService::class.java, + periodMillis = TimeUnit.HOURS.toMillis(24), + networkType = if (storage.requiresNetwork) JobInfo.NETWORK_TYPE_UNMETERED + else JobInfo.NETWORK_TYPE_NONE, + deviceIdle = false, + charging = true + ) + } + } + + private fun cancelBackupWorkers() { + AppBackupWorker.unschedule(app) + BackupJobService.cancelJob(app) + } + @WorkerThread private inner class InitializationObserver(val requestBackup: Boolean) : IBackupObserver.Stub() { From 8a870d89426530e10d1a573b67d4d03ebb455ed4 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 22 Feb 2024 13:09:48 -0300 Subject: [PATCH 10/22] Use WorkInfo for determining if a backup is already running Backup and restore is not possible when a backup is running. We used to check notifications for this, but now can use WorkManager's WorkInfo which should be more reliable. Also, we used to prevent the "Backup now" action when app backup was disabled. But the user may want to do a storage backup. This is now possible. --- .../seedvault/settings/SettingsFragment.kt | 20 +++++++------ .../seedvault/settings/SettingsViewModel.kt | 28 +++++++++---------- .../stevesoltys/seedvault/storage/Services.kt | 1 + .../notification/BackupNotificationManager.kt | 10 ------- 4 files changed, 26 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt index 559a9c44..c7375a03 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt @@ -141,10 +141,17 @@ class SettingsFragment : PreferenceFragmentCompat() { super.onViewCreated(view, savedInstanceState) viewModel.lastBackupTime.observe(viewLifecycleOwner) { time -> - setAppBackupStatusSummary(time, viewModel.nextScheduleTimeMillis.value) + setAppBackupStatusSummary( + lastBackupInMillis = time, + nextScheduleTimeMillis = viewModel.appBackupWorkInfo.value?.nextScheduleTimeMillis, + ) } - viewModel.nextScheduleTimeMillis.observe(viewLifecycleOwner) { time -> - setAppBackupStatusSummary(viewModel.lastBackupTime.value, time) + viewModel.appBackupWorkInfo.observe(viewLifecycleOwner) { workInfo -> + viewModel.onWorkerStateChanged() + setAppBackupStatusSummary( + lastBackupInMillis = viewModel.lastBackupTime.value, + nextScheduleTimeMillis = workInfo?.nextScheduleTimeMillis, + ) } val backupFiles: Preference = findPreference("backup_files")!! @@ -165,7 +172,7 @@ class SettingsFragment : PreferenceFragmentCompat() { setAutoRestoreState() setAppBackupStatusSummary( lastBackupInMillis = viewModel.lastBackupTime.value, - nextScheduleTimeMillis = viewModel.nextScheduleTimeMillis.value, + nextScheduleTimeMillis = viewModel.appBackupWorkInfo.value?.nextScheduleTimeMillis, ) } @@ -273,11 +280,6 @@ class SettingsFragment : PreferenceFragmentCompat() { if (sb.isNotEmpty()) sb.append("\n") // set time of next backup when (nextScheduleTimeMillis) { - -1L -> { - val text = getString(R.string.settings_backup_last_backup_never) - sb.append(getString(R.string.settings_backup_status_next_backup, text)) - } - Long.MAX_VALUE -> { val text = if (backupManager.isBackupEnabled && storage?.isUsb != true) { getString(R.string.notification_title) diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index c0aedf7b..73f0bb3d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -26,6 +26,7 @@ import androidx.lifecycle.map import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope import androidx.recyclerview.widget.DiffUtil.calculateDiff +import androidx.work.WorkInfo import androidx.work.WorkManager import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.crypto.KeyManager @@ -72,10 +73,9 @@ internal class SettingsViewModel( val backupPossible: LiveData = mBackupPossible internal val lastBackupTime = metadataManager.lastBackupTime - val nextScheduleTimeMillis = + internal val appBackupWorkInfo = workManager.getWorkInfosForUniqueWorkLiveData(UNIQUE_WORK_NAME).map { - if (it.size > 0) it[0].nextScheduleTimeMillis - else -1L + it.getOrNull(0) } private val mAppStatusList = lastBackupTime.switchMap { @@ -140,6 +140,14 @@ internal class SettingsViewModel( onStoragePropertiesChanged() } + fun onWorkerStateChanged() { + viewModelScope.launch(Dispatchers.IO) { + val canDo = settingsManager.canDoBackupNow() && + appBackupWorkInfo.value?.state != WorkInfo.State.RUNNING + mBackupPossible.postValue(canDo) + } + } + private fun onStoragePropertiesChanged() { val storage = settingsManager.getStorage() ?: return @@ -165,11 +173,8 @@ internal class SettingsViewModel( connectivityManager?.registerNetworkCallback(request, networkCallback) networkCallback.registered = true } - - viewModelScope.launch(Dispatchers.IO) { - val canDo = settingsManager.canDoBackupNow() - mBackupPossible.postValue(canDo) - } + // update whether we can do backups right now or not + onWorkerStateChanged() } override fun onCleared() { @@ -181,12 +186,7 @@ internal class SettingsViewModel( } internal fun backupNow() { - // maybe replace the check below with one that checks if our transport service is running - if (notificationManager.hasActiveBackupNotifications()) { - Toast.makeText(app, R.string.notification_backup_already_running, LENGTH_LONG).show() - } else if (!backupManager.isBackupEnabled) { - Toast.makeText(app, R.string.notification_backup_disabled, LENGTH_LONG).show() - } else viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(Dispatchers.IO) { if (settingsManager.isStorageBackupEnabled()) { val i = Intent(app, StorageBackupService::class.java) // this starts an app backup afterwards diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt index e42da2d3..59b7278e 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt @@ -23,6 +23,7 @@ force running with: adb shell cmd jobscheduler run -f com.stevesoltys.seedvault 0 */ + internal class StorageBackupJobService : BackupJobService(StorageBackupService::class.java) internal class StorageBackupService : BackupService() { diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt index bc3fb55d..5d100d78 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt @@ -192,16 +192,6 @@ internal class BackupNotificationManager(private val context: Context) { nm.notify(NOTIFICATION_ID_SUCCESS, notification) } - fun hasActiveBackupNotifications(): Boolean { - nm.activeNotifications.forEach { - if (it.packageName == context.packageName) { - if (it.id == NOTIFICATION_ID_BACKGROUND) return true - if (it.id == NOTIFICATION_ID_OBSERVER) return it.isOngoing - } - } - return false - } - @SuppressLint("RestrictedApi") fun onBackupError() { val intent = Intent(context, SettingsActivity::class.java) From e7e489e091e87d0d1b9662a698f0a93e69544c7a Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 22 Feb 2024 13:36:43 -0300 Subject: [PATCH 11/22] Only reschedule next app backup when not on USB storage Currently, after a manual run, we need to schedule the background backups again, because the scheduling gets lost. However, we need to be careful not to do that when the backup destination is on removable storage. Then we don't want to run. --- .../java/com/stevesoltys/seedvault/UsbIntentReceiver.kt | 2 +- .../stevesoltys/seedvault/settings/SettingsViewModel.kt | 3 ++- .../java/com/stevesoltys/seedvault/storage/Services.kt | 5 ++++- .../seedvault/ui/storage/BackupStorageViewModel.kt | 3 ++- .../com/stevesoltys/seedvault/worker/AppBackupWorker.kt | 8 ++++---- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt b/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt index ff5208b0..a14cd0c6 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt @@ -63,7 +63,7 @@ class UsbIntentReceiver : UsbMonitor() { i.putExtra(EXTRA_START_APP_BACKUP, true) startForegroundService(context, i) } else { - AppBackupWorker.scheduleNow(context) + AppBackupWorker.scheduleNow(context, reschedule = false) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index 73f0bb3d..2c421e14 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -193,7 +193,8 @@ internal class SettingsViewModel( i.putExtra(EXTRA_START_APP_BACKUP, true) startForegroundService(app, i) } else { - AppBackupWorker.scheduleNow(app) + val isUsb = settingsManager.getStorage()?.isUsb ?: false + AppBackupWorker.scheduleNow(app, reschedule = !isUsb) } } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt index 59b7278e..5a9096d3 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/storage/Services.kt @@ -1,6 +1,7 @@ package com.stevesoltys.seedvault.storage import android.content.Intent +import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.worker.AppBackupWorker import org.calyxos.backup.storage.api.BackupObserver import org.calyxos.backup.storage.api.RestoreObserver @@ -33,6 +34,7 @@ internal class StorageBackupService : BackupService() { } override val storageBackup: StorageBackup by inject() + private val settingsManager: SettingsManager by inject() // use lazy delegate because context isn't available during construction time override val backupObserver: BackupObserver by lazy { @@ -41,7 +43,8 @@ internal class StorageBackupService : BackupService() { override fun onBackupFinished(intent: Intent, success: Boolean) { if (intent.getBooleanExtra(EXTRA_START_APP_BACKUP, false)) { - AppBackupWorker.scheduleNow(applicationContext) + val isUsb = settingsManager.getStorage()?.isUsb ?: false + AppBackupWorker.scheduleNow(applicationContext, reschedule = !isUsb) } } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt index 01cc817f..141992ea 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt @@ -106,7 +106,8 @@ internal class BackupStorageViewModel( // notify the UI that the location has been set mLocationChecked.postEvent(LocationResult()) if (requestBackup) { - AppBackupWorker.scheduleNow(app) + val isUsb = settingsManager.getStorage()?.isUsb ?: false + AppBackupWorker.scheduleNow(app, reschedule = !isUsb) } } else { // notify the UI that the location was invalid diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt index 32c44a24..31587fd7 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt @@ -36,7 +36,7 @@ class AppBackupWorker( companion object { private val TAG = AppBackupWorker::class.simpleName internal const val UNIQUE_WORK_NAME = "com.stevesoltys.seedvault.APP_BACKUP" - private const val TAG_NOW = "com.stevesoltys.seedvault.TAG_NOW" + private const val TAG_RESCHEDULE = "com.stevesoltys.seedvault.TAG_RESCHEDULE" fun schedule(context: Context, existingWorkPolicy: ExistingPeriodicWorkPolicy = UPDATE) { val constraints = Constraints.Builder() @@ -56,10 +56,10 @@ class AppBackupWorker( workManager.enqueueUniquePeriodicWork(UNIQUE_WORK_NAME, existingWorkPolicy, workRequest) } - fun scheduleNow(context: Context) { + fun scheduleNow(context: Context, reschedule: Boolean) { val workRequest = OneTimeWorkRequestBuilder() .setExpedited(RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .addTag(TAG_NOW) + .apply { if (reschedule) addTag(TAG_RESCHEDULE) } .build() val workManager = WorkManager.getInstance(context) Log.i(TAG, "Asking to do app backup now...") @@ -93,7 +93,7 @@ class AppBackupWorker( } finally { // schedule next backup, because the old one gets lost // when scheduling a OneTimeWorkRequest with the same unique name via scheduleNow() - if (tags.contains(TAG_NOW) && backupRequester.isBackupEnabled) { + if (tags.contains(TAG_RESCHEDULE) && backupRequester.isBackupEnabled) { // needs to use CANCEL_AND_REENQUEUE otherwise it doesn't get scheduled schedule(applicationContext, CANCEL_AND_REENQUEUE) } From f593b66e00e80a75a918a78a41b14dbb23e15999 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 22 Feb 2024 13:55:24 -0300 Subject: [PATCH 12/22] try more than once to upload metadata after APK backups Failure to upload metadata after backup up APKs can be critical and flaky I/O can make it fail, so we try again. --- .../seedvault/worker/ApkBackupManager.kt | 22 +++++++++++-- .../seedvault/worker/ApkBackupManagerTest.kt | 33 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt index c75a8608..be1942c1 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt @@ -18,6 +18,7 @@ import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.transport.backup.isStopped import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.getAppName +import kotlinx.coroutines.delay import java.io.IOException import java.io.OutputStream @@ -46,9 +47,12 @@ internal class ApkBackupManager( backUpApks() } } finally { - // upload all local changes only at the end, so we don't have to re-upload the metadata - plugin.getMetadataOutputStream().use { outputStream -> - metadataManager.uploadMetadata(outputStream) + keepTrying { + // upload all local changes only at the end, + // so we don't have to re-upload the metadata + plugin.getMetadataOutputStream().use { outputStream -> + metadataManager.uploadMetadata(outputStream) + } } nm.onApkBackupDone() } @@ -109,6 +113,18 @@ internal class ApkBackupManager( } } + private suspend fun keepTrying(n: Int = 3, block: suspend () -> Unit) { + for (i in 1..n) { + try { + block() + } catch (e: Exception) { + if (i == n) throw e + Log.e(TAG, "Error (#$i), we'll keep trying", e) + delay(1000) + } + } + } + private suspend fun StoragePlugin.getMetadataOutputStream(token: Long? = null): OutputStream { val t = token ?: settingsManager.getToken() ?: throw IOException("no current token") return getOutputStream(t, FILE_BACKUP_METADATA) diff --git a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt index 8f863d3d..f12f4def 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt @@ -15,6 +15,7 @@ import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import io.mockk.Runs +import io.mockk.andThenJust import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -24,6 +25,7 @@ import io.mockk.verify import io.mockk.verifyAll import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Test +import java.io.IOException import java.io.OutputStream internal class ApkBackupManagerTest : TransportTest() { @@ -189,6 +191,37 @@ internal class ApkBackupManagerTest : TransportTest() { } } + @Test + fun `we keep trying to upload metadata at the end`() = runBlocking { + every { nm.onAppsNotBackedUp() } just Runs + every { packageService.notBackedUpPackages } returns listOf(packageInfo) + + every { + metadataManager.getPackageMetadata(packageInfo.packageName) + } returns packageMetadata + every { packageMetadata.state } returns UNKNOWN_ERROR + every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs + + every { settingsManager.backupApks() } returns false + + // final upload + every { settingsManager.getToken() } returns token + coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream + every { + metadataManager.uploadMetadata(metadataOutputStream) + } throws IOException() andThenThrows SecurityException() andThenJust Runs + every { metadataOutputStream.close() } just Runs + + every { nm.onApkBackupDone() } just Runs + + apkBackupManager.backup() + + verify { + metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) + metadataOutputStream.close() + } + } + private fun expectAllAppsWillGetBackedUp() { every { nm.onAppsNotBackedUp() } just Runs every { packageService.notBackedUpPackages } returns emptyList() From 4eaa806636126c2d52d9910540836ddd8203b197 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 22 Feb 2024 16:47:01 -0300 Subject: [PATCH 13/22] Expose scheduling options in the UI --- .../java/com/stevesoltys/seedvault/App.kt | 7 +- .../seedvault/UsbIntentReceiver.kt | 10 +-- .../settings/ExpertSettingsFragment.kt | 8 +- .../seedvault/settings/SchedulingFragment.kt | 64 ++++++++++++++++ .../seedvault/settings/SettingsFragment.kt | 75 ++++++++++--------- .../seedvault/settings/SettingsManager.kt | 22 ++++++ .../seedvault/settings/SettingsViewModel.kt | 14 ++-- .../ui/storage/BackupStorageViewModel.kt | 5 +- .../seedvault/worker/AppBackupWorker.kt | 44 ++++++++--- app/src/main/res/drawable/ic_access_time.xml | 10 +++ .../res/drawable/ic_battery_charging_full.xml | 10 +++ .../main/res/drawable/ic_network_warning.xml | 10 +++ app/src/main/res/values/arrays.xml | 20 +++++ app/src/main/res/values/strings.xml | 12 +++ app/src/main/res/xml/settings.xml | 7 ++ app/src/main/res/xml/settings_scheduling.xml | 34 +++++++++ 16 files changed, 287 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt create mode 100644 app/src/main/res/drawable/ic_access_time.xml create mode 100644 app/src/main/res/drawable/ic_battery_charging_full.xml create mode 100644 app/src/main/res/drawable/ic_network_warning.xml create mode 100644 app/src/main/res/values/arrays.xml create mode 100644 app/src/main/res/xml/settings_scheduling.xml diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index af6ac99a..a28ac628 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/App.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt @@ -13,6 +13,7 @@ import android.os.UserHandle import android.os.UserManager import android.provider.Settings import androidx.work.WorkManager +import androidx.work.ExistingPeriodicWorkPolicy.UPDATE import com.stevesoltys.seedvault.crypto.cryptoModule import com.stevesoltys.seedvault.header.headerModule import com.stevesoltys.seedvault.metadata.MetadataManager @@ -56,7 +57,7 @@ open class App : Application() { factory { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) } factory { AppListRetriever(this@App, get(), get(), get()) } - viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) } + viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get(), get()) } viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) } viewModel { BackupStorageViewModel(this@App, get(), get(), get()) } viewModel { RestoreStorageViewModel(this@App, get(), get()) } @@ -133,7 +134,9 @@ open class App : Application() { if (!isFrameworkSchedulingEnabled()) return // already on own scheduling backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), false) - if (backupManager.isBackupEnabled) AppBackupWorker.schedule(applicationContext) + if (backupManager.isBackupEnabled) { + AppBackupWorker.schedule(applicationContext, settingsManager, UPDATE) + } // cancel old D2D worker WorkManager.getInstance(this).cancelUniqueWork("APP_BACKUP") } diff --git a/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt b/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt index a14cd0c6..61155956 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt @@ -23,12 +23,10 @@ import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_ST import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE import com.stevesoltys.seedvault.worker.AppBackupWorker import org.koin.core.context.GlobalContext.get -import java.util.concurrent.TimeUnit.HOURS +import java.util.Date private val TAG = UsbIntentReceiver::class.java.simpleName -private const val HOURS_AUTO_BACKUP: Long = 24 - class UsbIntentReceiver : UsbMonitor() { // using KoinComponent would crash robolectric tests :( @@ -43,11 +41,13 @@ class UsbIntentReceiver : UsbMonitor() { return if (savedFlashDrive == attachedFlashDrive) { Log.d(TAG, "Matches stored device, checking backup time...") val backupMillis = System.currentTimeMillis() - metadataManager.getLastBackupTime() - if (backupMillis >= HOURS.toMillis(HOURS_AUTO_BACKUP)) { - Log.d(TAG, "Last backup older than 24 hours, requesting a backup...") + if (backupMillis >= settingsManager.backupFrequencyInMillis) { + Log.d(TAG, "Last backup older than it should be, requesting a backup...") + Log.d(TAG, " ${Date(metadataManager.getLastBackupTime())}") true } else { Log.d(TAG, "We have a recent backup, not requesting a new one.") + Log.d(TAG, " ${Date(metadataManager.getLastBackupTime())}") false } } else { diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt index c7e7d378..269f90cb 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt @@ -16,10 +16,10 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() { private val viewModel: SettingsViewModel by sharedViewModel() private val packageService: PackageService by inject() - // TODO set mimeType when upgrading androidx lib - private val createFileLauncher = registerForActivityResult(CreateDocument()) { uri -> - viewModel.onLogcatUriReceived(uri) - } + private val createFileLauncher = + registerForActivityResult(CreateDocument("text/plain")) { uri -> + viewModel.onLogcatUriReceived(uri) + } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { permitDiskReads { diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt new file mode 100644 index 00000000..be3796a6 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SchedulingFragment.kt @@ -0,0 +1,64 @@ +package com.stevesoltys.seedvault.settings + +import android.content.SharedPreferences +import android.os.Bundle +import android.view.View +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceManager +import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE +import androidx.work.ExistingPeriodicWorkPolicy.UPDATE +import com.stevesoltys.seedvault.R +import com.stevesoltys.seedvault.permitDiskReads +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.sharedViewModel + +class SchedulingFragment : PreferenceFragmentCompat(), + SharedPreferences.OnSharedPreferenceChangeListener { + + private val viewModel: SettingsViewModel by sharedViewModel() + private val settingsManager: SettingsManager by inject() + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + permitDiskReads { + setPreferencesFromResource(R.xml.settings_scheduling, rootKey) + PreferenceManager.setDefaultValues(requireContext(), R.xml.settings_scheduling, false) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val storage = settingsManager.getStorage() + if (storage?.isUsb == true) { + findPreference("scheduling_category_conditions")?.isEnabled = false + } + } + + override fun onStart() { + super.onStart() + + activity?.setTitle(R.string.settings_backup_scheduling_title) + } + + override fun onResume() { + super.onResume() + settingsManager.registerOnSharedPreferenceChangeListener(this) + } + + override fun onPause() { + super.onPause() + settingsManager.unregisterOnSharedPreferenceChangeListener(this) + } + + // we can not use setOnPreferenceChangeListener() because that gets called + // before prefs were saved + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + PREF_KEY_SCHED_FREQ -> viewModel.scheduleAppBackup(CANCEL_AND_REENQUEUE) + PREF_KEY_SCHED_METERED -> viewModel.scheduleAppBackup(UPDATE) + PREF_KEY_SCHED_CHARGING -> viewModel.scheduleAppBackup(UPDATE) + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt index c7375a03..33fe15dc 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt @@ -19,12 +19,15 @@ import androidx.preference.Preference import androidx.preference.Preference.OnPreferenceChangeListener import androidx.preference.PreferenceFragmentCompat import androidx.preference.TwoStatePreference +import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE +import androidx.work.WorkInfo import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.restore.RestoreActivity import com.stevesoltys.seedvault.ui.toRelativeTime import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.sharedViewModel +import java.util.concurrent.TimeUnit private val TAG = SettingsFragment::class.java.name @@ -39,6 +42,7 @@ class SettingsFragment : PreferenceFragmentCompat() { private lateinit var apkBackup: TwoStatePreference private lateinit var backupLocation: Preference private lateinit var backupStatus: Preference + private lateinit var backupScheduling: Preference private lateinit var backupStorage: TwoStatePreference private lateinit var backupRecoveryCode: Preference @@ -121,6 +125,7 @@ class SettingsFragment : PreferenceFragmentCompat() { return@OnPreferenceChangeListener false } backupStatus = findPreference("backup_status")!! + backupScheduling = findPreference("backup_scheduling")!! backupStorage = findPreference("backup_storage")!! backupStorage.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue -> @@ -141,17 +146,11 @@ class SettingsFragment : PreferenceFragmentCompat() { super.onViewCreated(view, savedInstanceState) viewModel.lastBackupTime.observe(viewLifecycleOwner) { time -> - setAppBackupStatusSummary( - lastBackupInMillis = time, - nextScheduleTimeMillis = viewModel.appBackupWorkInfo.value?.nextScheduleTimeMillis, - ) + setAppBackupStatusSummary(time) } viewModel.appBackupWorkInfo.observe(viewLifecycleOwner) { workInfo -> viewModel.onWorkerStateChanged() - setAppBackupStatusSummary( - lastBackupInMillis = viewModel.lastBackupTime.value, - nextScheduleTimeMillis = workInfo?.nextScheduleTimeMillis, - ) + setAppBackupSchedulingSummary(workInfo) } val backupFiles: Preference = findPreference("backup_files")!! @@ -170,10 +169,8 @@ class SettingsFragment : PreferenceFragmentCompat() { setBackupEnabledState() setBackupLocationSummary() setAutoRestoreState() - setAppBackupStatusSummary( - lastBackupInMillis = viewModel.lastBackupTime.value, - nextScheduleTimeMillis = viewModel.appBackupWorkInfo.value?.nextScheduleTimeMillis, - ) + setAppBackupStatusSummary(viewModel.lastBackupTime.value) + setAppBackupSchedulingSummary(viewModel.appBackupWorkInfo.value) } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { @@ -221,7 +218,7 @@ class SettingsFragment : PreferenceFragmentCompat() { return try { backupManager.isBackupEnabled = enabled if (enabled) { - viewModel.scheduleAppBackup() + viewModel.scheduleAppBackup(CANCEL_AND_REENQUEUE) viewModel.enableCallLogBackup() } else { viewModel.cancelAppBackup() @@ -265,37 +262,41 @@ class SettingsFragment : PreferenceFragmentCompat() { backupLocation.summary = storage?.name ?: getString(R.string.settings_backup_location_none) } - private fun setAppBackupStatusSummary( - lastBackupInMillis: Long?, - nextScheduleTimeMillis: Long?, - ) { - val sb = StringBuilder() + private fun setAppBackupStatusSummary(lastBackupInMillis: Long?) { if (lastBackupInMillis != null) { // set time of last backup val lastBackup = lastBackupInMillis.toRelativeTime(requireContext()) - sb.append(getString(R.string.settings_backup_status_summary, lastBackup)) + backupStatus.summary = getString(R.string.settings_backup_status_summary, lastBackup) } - if (nextScheduleTimeMillis != null) { - // insert linebreak, if we have text before - if (sb.isNotEmpty()) sb.append("\n") - // set time of next backup - when (nextScheduleTimeMillis) { - Long.MAX_VALUE -> { - val text = if (backupManager.isBackupEnabled && storage?.isUsb != true) { - getString(R.string.notification_title) - } else { - getString(R.string.settings_backup_last_backup_never) - } - sb.append(getString(R.string.settings_backup_status_next_backup, text)) - } + } - else -> { - val text = nextScheduleTimeMillis.toRelativeTime(requireContext()) - sb.append(getString(R.string.settings_backup_status_next_backup_estimate, text)) - } + private fun setAppBackupSchedulingSummary(workInfo: WorkInfo?) { + if (storage?.isUsb == true) { + backupScheduling.summary = getString(R.string.settings_backup_status_next_backup_usb) + return + } + if (workInfo == null) return + + val nextScheduleTimeMillis = workInfo.nextScheduleTimeMillis + if (workInfo.state == WorkInfo.State.RUNNING) { + val text = getString(R.string.notification_title) + backupScheduling.summary = getString(R.string.settings_backup_status_next_backup, text) + } else if (nextScheduleTimeMillis == Long.MAX_VALUE) { + val text = getString(R.string.settings_backup_last_backup_never) + backupScheduling.summary = getString(R.string.settings_backup_status_next_backup, text) + } else { + val diff = System.currentTimeMillis() - nextScheduleTimeMillis + val isPast = diff > TimeUnit.MINUTES.toMillis(1) + if (isPast) { + val text = getString(R.string.settings_backup_status_next_backup_past) + backupScheduling.summary = + getString(R.string.settings_backup_status_next_backup, text) + } else { + val text = nextScheduleTimeMillis.toRelativeTime(requireContext()) + backupScheduling.summary = + getString(R.string.settings_backup_status_next_backup_estimate, text) } } - backupStatus.summary = sb.toString() } private fun onEnablingStorageBackup() { diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt index 47176a09..5990dcb1 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt @@ -1,6 +1,7 @@ package com.stevesoltys.seedvault.settings import android.content.Context +import android.content.SharedPreferences.OnSharedPreferenceChangeListener import android.hardware.usb.UsbDevice import android.net.ConnectivityManager import android.net.NetworkCapabilities @@ -17,6 +18,9 @@ import java.util.concurrent.ConcurrentSkipListSet internal const val PREF_KEY_TOKEN = "token" internal const val PREF_KEY_BACKUP_APK = "backup_apk" internal const val PREF_KEY_AUTO_RESTORE = "auto_restore" +internal const val PREF_KEY_SCHED_FREQ = "scheduling_frequency" +internal const val PREF_KEY_SCHED_METERED = "scheduling_metered" +internal const val PREF_KEY_SCHED_CHARGING = "scheduling_charging" private const val PREF_KEY_STORAGE_URI = "storageUri" private const val PREF_KEY_STORAGE_NAME = "storageName" @@ -43,6 +47,14 @@ class SettingsManager(private val context: Context) { @Volatile private var token: Long? = null + fun registerOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) { + prefs.registerOnSharedPreferenceChangeListener(listener) + } + + fun unregisterOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) { + prefs.unregisterOnSharedPreferenceChangeListener(listener) + } + /** * This gets accessed by non-UI threads when saving with [PreferenceManager] * and when [isBackupEnabled] is called during a backup run. @@ -141,6 +153,16 @@ class SettingsManager(private val context: Context) { return prefs.getBoolean(PREF_KEY_BACKUP_APK, true) } + val backupFrequencyInMillis: Long + get() { + return prefs.getString(PREF_KEY_SCHED_FREQ, "86400000")?.toLongOrNull() + ?: 86400000 // 24h + } + val useMeteredNetwork: Boolean + get() = prefs.getBoolean(PREF_KEY_SCHED_METERED, false) + val backupOnlyWhenCharging: Boolean + get() = prefs.getBoolean(PREF_KEY_SCHED_CHARGING, true) + fun isBackupEnabled(packageName: String) = !blacklistedApps.contains(packageName) fun isStorageBackupEnabled() = prefs.getBoolean(PREF_KEY_BACKUP_STORAGE, false) diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index 2c421e14..b80ed331 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -26,6 +26,8 @@ import androidx.lifecycle.map import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope import androidx.recyclerview.widget.DiffUtil.calculateDiff +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE import androidx.work.WorkInfo import androidx.work.WorkManager import com.stevesoltys.seedvault.R @@ -36,7 +38,6 @@ import com.stevesoltys.seedvault.storage.StorageBackupJobService import com.stevesoltys.seedvault.storage.StorageBackupService import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel -import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.worker.AppBackupWorker import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME import kotlinx.coroutines.Dispatchers @@ -55,7 +56,6 @@ internal class SettingsViewModel( app: Application, settingsManager: SettingsManager, keyManager: KeyManager, - private val notificationManager: BackupNotificationManager, private val metadataManager: MetadataManager, private val appListRetriever: AppListRetriever, private val storageBackup: StorageBackup, @@ -126,7 +126,7 @@ internal class SettingsViewModel( override fun onStorageLocationChanged() { val storage = settingsManager.getStorage() ?: return - Log.i(TAG, "onStorageLocationChanged") + Log.i(TAG, "onStorageLocationChanged (isUsb: ${storage.isUsb}") if (storage.isUsb) { // disable storage backup if new storage is on USB cancelAppBackup() @@ -134,7 +134,7 @@ internal class SettingsViewModel( } else { // enable it, just in case the previous storage was on USB, // also to update the network requirement of the new storage - scheduleAppBackup() + scheduleAppBackup(CANCEL_AND_REENQUEUE) scheduleFilesBackup() } onStoragePropertiesChanged() @@ -248,9 +248,11 @@ internal class SettingsViewModel( return keyManager.hasMainKey() } - fun scheduleAppBackup() { + fun scheduleAppBackup(existingWorkPolicy: ExistingPeriodicWorkPolicy) { val storage = settingsManager.getStorage() ?: error("no storage available") - if (!storage.isUsb && backupManager.isBackupEnabled) AppBackupWorker.schedule(app) + if (!storage.isUsb && backupManager.isBackupEnabled) { + AppBackupWorker.schedule(app, settingsManager, existingWorkPolicy) + } } fun scheduleFilesBackup() { diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt index 141992ea..7a3a06a6 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt @@ -10,6 +10,7 @@ import android.os.UserHandle import android.util.Log import androidx.annotation.WorkerThread import androidx.lifecycle.viewModelScope +import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.storage.StorageBackupJobService @@ -69,7 +70,9 @@ internal class BackupStorageViewModel( private fun scheduleBackupWorkers() { val storage = settingsManager.getStorage() ?: error("no storage available") if (!storage.isUsb) { - if (backupManager.isBackupEnabled) AppBackupWorker.schedule(app) + if (backupManager.isBackupEnabled) { + AppBackupWorker.schedule(app, settingsManager, CANCEL_AND_REENQUEUE) + } if (settingsManager.isStorageBackupEnabled()) BackupJobService.scheduleJob( context = app, jobServiceClass = StorageBackupJobService::class.java, diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt index 31587fd7..64a1cbed 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt @@ -7,13 +7,13 @@ package com.stevesoltys.seedvault.worker import android.content.Context import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.text.format.DateUtils.formatElapsedTime import android.util.Log import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE -import androidx.work.ExistingPeriodicWorkPolicy.UPDATE import androidx.work.ExistingWorkPolicy.REPLACE import androidx.work.ForegroundInfo import androidx.work.NetworkType @@ -22,6 +22,7 @@ import androidx.work.OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters +import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.NOTIFICATION_ID_OBSERVER import org.koin.core.component.KoinComponent @@ -38,21 +39,43 @@ class AppBackupWorker( internal const val UNIQUE_WORK_NAME = "com.stevesoltys.seedvault.APP_BACKUP" private const val TAG_RESCHEDULE = "com.stevesoltys.seedvault.TAG_RESCHEDULE" - fun schedule(context: Context, existingWorkPolicy: ExistingPeriodicWorkPolicy = UPDATE) { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.UNMETERED) - .setRequiresCharging(true) - .build() + /** + * (Re-)schedules the [AppBackupWorker]. + * + * @param existingWorkPolicy usually you want to use [ExistingPeriodicWorkPolicy.UPDATE] + * only if you are sure that work is still scheduled + * and you don't want to mess with the scheduling time. + * In most other cases, you want to use [ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE], + * because it ensures work gets schedules, even if it wasn't scheduled before. + * It will however reset the scheduling time. + */ + fun schedule( + context: Context, + settingsManager: SettingsManager, + existingWorkPolicy: ExistingPeriodicWorkPolicy, + ) { + val logFrequency = formatElapsedTime(settingsManager.backupFrequencyInMillis / 1000) + Log.i(TAG, "Scheduling in $logFrequency...") + val constraints = Constraints.Builder().apply { + if (!settingsManager.useMeteredNetwork) { + Log.i(TAG, " only on unmetered networks") + setRequiredNetworkType(NetworkType.UNMETERED) + } + if (settingsManager.backupOnlyWhenCharging) { + Log.i(TAG, " only when the device is charging") + setRequiresCharging(true) + } + }.build() val workRequest = PeriodicWorkRequestBuilder( - repeatInterval = 24, - repeatIntervalTimeUnit = TimeUnit.HOURS, + repeatInterval = settingsManager.backupFrequencyInMillis, + repeatIntervalTimeUnit = TimeUnit.MILLISECONDS, flexTimeInterval = 2, flexTimeIntervalUnit = TimeUnit.HOURS, ).setConstraints(constraints) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.MINUTES) .build() val workManager = WorkManager.getInstance(context) - Log.i(TAG, "Scheduling app backup: $workRequest") + Log.i(TAG, " workRequest: ${workRequest.id}") workManager.enqueueUniquePeriodicWork(UNIQUE_WORK_NAME, existingWorkPolicy, workRequest) } @@ -74,6 +97,7 @@ class AppBackupWorker( } private val backupRequester: BackupRequester by inject() + private val settingsManager: SettingsManager by inject() private val apkBackupManager: ApkBackupManager by inject() private val nm: BackupNotificationManager by inject() @@ -95,7 +119,7 @@ class AppBackupWorker( // when scheduling a OneTimeWorkRequest with the same unique name via scheduleNow() if (tags.contains(TAG_RESCHEDULE) && backupRequester.isBackupEnabled) { // needs to use CANCEL_AND_REENQUEUE otherwise it doesn't get scheduled - schedule(applicationContext, CANCEL_AND_REENQUEUE) + schedule(applicationContext, settingsManager, CANCEL_AND_REENQUEUE) } } } diff --git a/app/src/main/res/drawable/ic_access_time.xml b/app/src/main/res/drawable/ic_access_time.xml new file mode 100644 index 00000000..2b1853f2 --- /dev/null +++ b/app/src/main/res/drawable/ic_access_time.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_battery_charging_full.xml b/app/src/main/res/drawable/ic_battery_charging_full.xml new file mode 100644 index 00000000..92496d40 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_charging_full.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_network_warning.xml b/app/src/main/res/drawable/ic_network_warning.xml new file mode 100644 index 00000000..a2419809 --- /dev/null +++ b/app/src/main/res/drawable/ic_network_warning.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml new file mode 100644 index 00000000..aaa63556 --- /dev/null +++ b/app/src/main/res/values/arrays.xml @@ -0,0 +1,20 @@ + + + + + @string/settings_scheduling_frequency_12_hours + @string/settings_scheduling_frequency_daily + @string/settings_scheduling_frequency_3_days + @string/settings_scheduling_frequency_weekly + + + + 43200000 + 86400000 + 259200000 + 604800000 + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 13c09436..a961a078 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,6 +32,9 @@ Last backup: %1$s Next backup: %1$s Next backup (estimate): %1$s + once conditions are fulfilled + Backups will happen automatically when you plug in your USB drive + Backup scheduling Exclude apps Backup now Storage backup (beta) @@ -48,6 +51,15 @@ To continue using app backups, you need to generate a new recovery code.\n\nWe are sorry for the inconvenience. New code + Backup frequency + Every 12 hours + Daily + Every 3 days + Weekly + Conditions + Back up when using mobile data + Back up only when charging + Expert settings Unlimited app quota Do not impose a limitation on the size of app backups.\n\nWarning: This can fill up your storage location quickly. Not needed for most apps. diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml index e9034e67..784f0400 100644 --- a/app/src/main/res/xml/settings.xml +++ b/app/src/main/res/xml/settings.xml @@ -46,6 +46,13 @@ app:summary="@string/settings_backup_apk_summary" app:title="@string/settings_backup_apk_title" /> + + diff --git a/app/src/main/res/xml/settings_scheduling.xml b/app/src/main/res/xml/settings_scheduling.xml new file mode 100644 index 00000000..d5bccf1e --- /dev/null +++ b/app/src/main/res/xml/settings_scheduling.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + From 19bfc41d95144375dd52ab926c3baade2f4f24ed Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 23 Feb 2024 11:35:26 -0300 Subject: [PATCH 14/22] Allow backups in metered network, if user wants that --- .../seedvault/settings/SettingsManager.kt | 12 +++++++----- .../seedvault/transport/backup/BackupCoordinator.kt | 5 ++++- .../transport/backup/BackupCoordinatorTest.kt | 2 ++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt index 5990dcb1..786f5781 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt @@ -146,7 +146,8 @@ class SettingsManager(private val context: Context) { fun canDoBackupNow(): Boolean { val storage = getStorage() ?: return false val systemContext = context.getStorageContext { storage.isUsb } - return !storage.isUnavailableUsb(systemContext) && !storage.isUnavailableNetwork(context) + return !storage.isUnavailableUsb(systemContext) && + !storage.isUnavailableNetwork(context, useMeteredNetwork) } fun backupApks(): Boolean { @@ -208,15 +209,16 @@ data class Storage( * Returns true if this is storage that requires network access, * but it isn't available right now. */ - fun isUnavailableNetwork(context: Context): Boolean { - return requiresNetwork && !hasUnmeteredInternet(context) + fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean { + return requiresNetwork && !hasUnmeteredInternet(context, allowMetered) } - private fun hasUnmeteredInternet(context: Context): Boolean { + private fun hasUnmeteredInternet(context: Context, allowMetered: Boolean): Boolean { val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false val isMetered = cm.isActiveNetworkMetered val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false - return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && !isMetered + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + (allowMetered || !isMetered) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt index 1dfbbce0..e9275bf7 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt @@ -422,7 +422,10 @@ internal class BackupCoordinator( // back off if storage is removable and not available right now storage.isUnavailableUsb(context) -> longBackoff // back off if storage is on network, but we have no access - storage.isUnavailableNetwork(context) -> HOURS.toMillis(1) + storage.isUnavailableNetwork( + context = context, + allowMetered = settingsManager.useMeteredNetwork, + ) -> HOURS.toMillis(1) // otherwise no back off else -> 0L } diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt index a883aaa4..2fed54ab 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt @@ -294,6 +294,7 @@ internal class BackupCoordinatorTest : BackupTest() { } just Runs coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs every { settingsManager.getStorage() } returns storage + every { settingsManager.useMeteredNetwork } returns false every { metadataOutputStream.close() } just Runs assertEquals( @@ -343,6 +344,7 @@ internal class BackupCoordinatorTest : BackupTest() { } just Runs coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs every { settingsManager.getStorage() } returns storage + every { settingsManager.useMeteredNetwork } returns false every { metadataOutputStream.close() } just Runs assertEquals( From aa1c7106241b403aa41344e2f4e3c15f3ab2b05c Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 26 Feb 2024 10:48:56 -0300 Subject: [PATCH 15/22] Stop running instrumentation tests for SDK 33 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bf8aa1b8..ae649ce7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - android_target: [ 33, 34 ] + android_target: [ 34 ] emulator_type: [ aosp_atd ] d2d_backup_test: [ true, false ] steps: From 23787a373e07c17c0298414afff9486a38142b0d Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 26 Feb 2024 11:22:34 -0300 Subject: [PATCH 16/22] Don't retry instrumentation tests --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ae649ce7..372144ca 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,7 +40,7 @@ jobs: - name: Run tests uses: Wandalen/wretry.action@v1.3.0 with: - attempt_limit: 3 + attempt_limit: 1 action: reactivecircus/android-emulator-runner@v2 with: | api-level: ${{ matrix.android_target }} From 2da989971bf1d6efc67ef4958736087a7b10fa70 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 26 Feb 2024 16:07:16 -0300 Subject: [PATCH 17/22] Request @pm@ backup after initialization to avoid a 2nd restore set being used. This also changes the initialization behavior to only create the restore set folder and upload the metadata only when we actually need to. This way, double inits are not creating new restore sets on the backup destination. --- .../seedvault/KoinInstrumentationTestApp.kt | 2 +- .../java/com/stevesoltys/seedvault/App.kt | 2 +- .../seedvault/metadata/MetadataManager.kt | 9 ++- .../saf/DocumentsProviderStoragePlugin.kt | 9 --- .../transport/backup/BackupCoordinator.kt | 26 +++---- .../transport/backup/BackupInitializer.kt | 73 +++++++++++++++++++ .../transport/backup/BackupModule.kt | 1 + .../ui/recoverycode/RecoveryCodeViewModel.kt | 14 ++-- .../ui/storage/BackupStorageViewModel.kt | 60 +++++---------- .../seedvault/metadata/MetadataManagerTest.kt | 2 +- .../transport/backup/BackupCoordinatorTest.kt | 7 +- 11 files changed, 118 insertions(+), 87 deletions(-) create mode 100644 app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupInitializer.kt diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt index 6a2e5601..c00438f2 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt @@ -47,7 +47,7 @@ class KoinInstrumentationTestApp : App() { viewModel { currentBackupStorageViewModel = - spyk(BackupStorageViewModel(context, get(), get(), get())) + spyk(BackupStorageViewModel(context, get(), get(), get(), get())) currentBackupStorageViewModel!! } diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index a28ac628..9391a801 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/App.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt @@ -59,7 +59,7 @@ open class App : Application() { viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get(), get()) } viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) } - viewModel { BackupStorageViewModel(this@App, get(), get(), get()) } + viewModel { BackupStorageViewModel(this@App, get(), get(), get(), get()) } viewModel { RestoreStorageViewModel(this@App, get(), get()) } viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get()) } viewModel { FileSelectionViewModel(this@App, get()) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt index 5c3d1a7a..bd5b4f38 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt @@ -59,14 +59,15 @@ internal class MetadataManager( /** * Call this when initializing a new device. * - * Existing [BackupMetadata] will be cleared, use the given new token, - * and written encrypted to the given [OutputStream] as well as the internal cache. + * Existing [BackupMetadata] will be cleared + * and new metadata with the given [token] will be written to the internal cache + * with a fresh salt. */ @Synchronized @Throws(IOException::class) - fun onDeviceInitialization(token: Long, metadataOutputStream: OutputStream) { + fun onDeviceInitialization(token: Long) { val salt = crypto.getRandomBytes(METADATA_SALT_SIZE).encodeBase64() - modifyMetadata(metadataOutputStream) { + modifyCachedMetadata { metadata = BackupMetadata(token = token, salt = salt) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt index 0dbc6c50..e8e02baa 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt @@ -35,22 +35,13 @@ internal class DocumentsProviderStoragePlugin( override suspend fun startNewRestoreSet(token: Long) { // reset current storage storage.reset(token) - - // get or create root backup dir - storage.rootBackupDir ?: throw IOException() } @Throws(IOException::class) override suspend fun initializeDevice() { - // wipe existing data - storage.getSetDir()?.deleteContents(context) - // reset storage without new token, so folders get recreated // otherwise stale DocumentFiles will hang around storage.reset(null) - - // create backup folders - storage.currentSetDir ?: throw IOException() } @Throws(IOException::class) diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt index e9275bf7..5846c4aa 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt @@ -86,12 +86,13 @@ internal class BackupCoordinator( * @return the token of the new [RestoreSet]. */ @Throws(IOException::class) - private suspend fun startNewRestoreSet(): Long { + private suspend fun startNewRestoreSet() { val token = clock.time() Log.i(TAG, "Starting new RestoreSet with token $token...") settingsManager.setNewToken(token) plugin.startNewRestoreSet(token) - return token + Log.d(TAG, "Resetting backup metadata...") + metadataManager.onDeviceInitialization(token) } /** @@ -115,18 +116,14 @@ internal class BackupCoordinator( suspend fun initializeDevice(): Int = try { // we don't respect the intended system behavior here by always starting a new [RestoreSet] // instead of simply deleting the current one - val token = startNewRestoreSet() + startNewRestoreSet() Log.i(TAG, "Initialize Device!") plugin.initializeDevice() - Log.d(TAG, "Resetting backup metadata for token $token...") - plugin.getMetadataOutputStream(token).use { - metadataManager.onDeviceInitialization(token, it) - } // [finishBackup] will only be called when we return [TRANSPORT_OK] here // so we remember that we initialized successfully state.calledInitialize = true TRANSPORT_OK - } catch (e: IOException) { + } catch (e: Exception) { Log.e(TAG, "Error initializing device", e) // Show error notification if we needed init or were ready for backups if (metadataManager.requiresInit || settingsManager.canDoBackupNow()) nm.onBackupError() @@ -222,14 +219,11 @@ internal class BackupCoordinator( state.cancelReason = UNKNOWN_ERROR if (metadataManager.requiresInit) { Log.w(TAG, "Metadata requires re-init!") - // start a new restore set to upgrade from legacy format - // by starting a clean backup with all files using the new version - try { - startNewRestoreSet() - } catch (e: IOException) { - Log.e(TAG, "Error starting new restore set", e) - } - // this causes a backup error, but things should go back to normal afterwards + // Tell the system that we are not initialized, it will initialize us afterwards. + // This will start a new restore set to upgrade from legacy format + // by starting a clean backup with all files using the new version. + // + // This causes a backup error, but things should go back to normal afterwards. return TRANSPORT_NOT_INITIALIZED } val token = settingsManager.getToken() ?: error("no token in performFullBackup") diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupInitializer.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupInitializer.kt new file mode 100644 index 00000000..9d83d618 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupInitializer.kt @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.backup + +import android.app.backup.BackupProgress +import android.app.backup.IBackupManager +import android.app.backup.IBackupObserver +import android.os.UserHandle +import android.util.Log +import androidx.annotation.WorkerThread +import com.stevesoltys.seedvault.BackupMonitor +import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER +import com.stevesoltys.seedvault.transport.TRANSPORT_ID + +class BackupInitializer( + private val backupManager: IBackupManager, +) { + + companion object { + private val TAG = BackupInitializer::class.simpleName + } + + fun initialize(onError: () -> Unit, onSuccess: () -> Unit) { + val observer = BackupObserver("Initialization", onError) { + // After successful initialization, we request a @pm@ backup right away, + // because if this finds empty state, it asks us to do another initialization. + // And then we end up with yet another restore set token. + // Since we want the final token as soon as possible, we need to get it here. + Log.d(TAG, "Requesting initial $MAGIC_PACKAGE_MANAGER backup...") + backupManager.requestBackup( + arrayOf(MAGIC_PACKAGE_MANAGER), + BackupObserver("Initial backup of @pm@", onError, onSuccess), + BackupMonitor(), + 0, + ) + } + backupManager.initializeTransportsForUser( + UserHandle.myUserId(), + arrayOf(TRANSPORT_ID), + observer, + ) + } + + @WorkerThread + private inner class BackupObserver( + private val operation: String, + private val onError: () -> Unit, + private val onSuccess: () -> Unit, + ) : IBackupObserver.Stub() { + override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) { + // noop + } + + override fun onResult(target: String, status: Int) { + // noop + } + + override fun backupFinished(status: Int) { + if (Log.isLoggable(TAG, Log.INFO)) { + Log.i(TAG, "$operation finished. Status: $status") + } + if (status == 0) { + onSuccess() + } else { + onError() + } + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt index 3ed9caed..6bb6f6e2 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt @@ -4,6 +4,7 @@ import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val backupModule = module { + single { BackupInitializer(get()) } single { InputFactory() } single { PackageService( diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt index ba126ab1..274187c3 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeViewModel.kt @@ -1,7 +1,6 @@ package com.stevesoltys.seedvault.ui.recoverycode import android.app.backup.IBackupManager -import android.os.UserHandle import android.util.Log import androidx.lifecycle.AndroidViewModel import cash.z.ecc.android.bip39.Mnemonics @@ -12,8 +11,7 @@ import cash.z.ecc.android.bip39.toSeed import com.stevesoltys.seedvault.App import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.KeyManager -import com.stevesoltys.seedvault.transport.TRANSPORT_ID -import com.stevesoltys.seedvault.transport.backup.BackupCoordinator +import com.stevesoltys.seedvault.transport.backup.BackupInitializer import com.stevesoltys.seedvault.ui.LiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager @@ -32,7 +30,7 @@ internal class RecoveryCodeViewModel( private val crypto: Crypto, private val keyManager: KeyManager, private val backupManager: IBackupManager, - private val backupCoordinator: BackupCoordinator, + private val backupInitializer: BackupInitializer, private val notificationManager: BackupNotificationManager, private val storageBackup: StorageBackup, ) : AndroidViewModel(app) { @@ -109,11 +107,9 @@ internal class RecoveryCodeViewModel( storageBackup.clearCache() try { // initialize the new location - if (backupManager.isBackupEnabled) backupManager.initializeTransportsForUser( - UserHandle.myUserId(), - arrayOf(TRANSPORT_ID), - null - ) + if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) { + // no-op + } } catch (e: IOException) { Log.e(TAG, "Error starting new RestoreSet", e) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt index 7a3a06a6..bf6c729b 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/BackupStorageViewModel.kt @@ -1,20 +1,16 @@ package com.stevesoltys.seedvault.ui.storage import android.app.Application -import android.app.backup.BackupProgress import android.app.backup.IBackupManager -import android.app.backup.IBackupObserver import android.app.job.JobInfo import android.net.Uri -import android.os.UserHandle import android.util.Log -import androidx.annotation.WorkerThread import androidx.lifecycle.viewModelScope import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.storage.StorageBackupJobService -import com.stevesoltys.seedvault.transport.TRANSPORT_ID +import com.stevesoltys.seedvault.transport.backup.BackupInitializer import com.stevesoltys.seedvault.worker.AppBackupWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -28,6 +24,7 @@ private val TAG = BackupStorageViewModel::class.java.simpleName internal class BackupStorageViewModel( private val app: Application, private val backupManager: IBackupManager, + private val backupInitializer: BackupInitializer, private val storageBackup: StorageBackup, settingsManager: SettingsManager, ) : StorageViewModel(app, settingsManager) { @@ -52,13 +49,23 @@ internal class BackupStorageViewModel( storageBackup.clearCache() try { // initialize the new location (if backups are enabled) - if (backupManager.isBackupEnabled) backupManager.initializeTransportsForUser( - UserHandle.myUserId(), - arrayOf(TRANSPORT_ID), - // if storage is on USB and this is not SetupWizard, do a backup right away - InitializationObserver(isUsb && !isSetupWizard) - ) else { - InitializationObserver(false).backupFinished(0) + if (backupManager.isBackupEnabled) { + val onError = { + Log.e(TAG, "Error starting new RestoreSet") + onInitializationError() + } + backupInitializer.initialize(onError) { + val requestBackup = isUsb && !isSetupWizard + if (requestBackup) { + Log.i(TAG, "Requesting a backup now, because we use USB storage") + AppBackupWorker.scheduleNow(app, reschedule = false) + } + // notify the UI that the location has been set + mLocationChecked.postEvent(LocationResult()) + } + } else { + // notify the UI that the location has been set + mLocationChecked.postEvent(LocationResult()) } } catch (e: IOException) { Log.e(TAG, "Error starting new RestoreSet", e) @@ -90,35 +97,6 @@ internal class BackupStorageViewModel( BackupJobService.cancelJob(app) } - @WorkerThread - private inner class InitializationObserver(val requestBackup: Boolean) : - IBackupObserver.Stub() { - override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) { - // noop - } - - override fun onResult(target: String, status: Int) { - // noop - } - - override fun backupFinished(status: Int) { - if (Log.isLoggable(TAG, Log.INFO)) { - Log.i(TAG, "Initialization finished. Status: $status") - } - if (status == 0) { - // notify the UI that the location has been set - mLocationChecked.postEvent(LocationResult()) - if (requestBackup) { - val isUsb = settingsManager.getStorage()?.isUsb ?: false - AppBackupWorker.scheduleNow(app, reschedule = !isUsb) - } - } else { - // notify the UI that the location was invalid - onInitializationError() - } - } - } - private fun onInitializationError() { val errorMsg = app.getString(R.string.storage_check_fragment_backup_error) mLocationChecked.postEvent(LocationResult(errorMsg)) diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt index f3dbb016..62300e76 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt @@ -100,7 +100,7 @@ class MetadataManagerTest { expectReadFromCache() expectModifyMetadata(initialMetadata) - manager.onDeviceInitialization(token, storageOutputStream) + manager.onDeviceInitialization(token) assertEquals(token, manager.getBackupToken()) assertEquals(0L, manager.getLastBackupTime()) diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt index 2fed54ab..598a6ff4 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt @@ -69,22 +69,18 @@ internal class BackupCoordinatorTest : BackupTest() { fun `device initialization succeeds and delegates to plugin`() = runBlocking { expectStartNewRestoreSet() coEvery { plugin.initializeDevice() } just Runs - coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream - every { metadataManager.onDeviceInitialization(token, metadataOutputStream) } just Runs every { kv.hasState() } returns false every { full.hasState() } returns false - every { metadataOutputStream.close() } just Runs assertEquals(TRANSPORT_OK, backup.initializeDevice()) assertEquals(TRANSPORT_OK, backup.finishBackup()) - - verify { metadataOutputStream.close() } } private suspend fun expectStartNewRestoreSet() { every { clock.time() } returns token every { settingsManager.setNewToken(token) } just Runs coEvery { plugin.startNewRestoreSet(token) } just Runs + every { metadataManager.onDeviceInitialization(token) } just Runs } @Test @@ -136,6 +132,7 @@ internal class BackupCoordinatorTest : BackupTest() { every { clock.time() } returns token + 1 every { settingsManager.setNewToken(token + 1) } just Runs coEvery { plugin.startNewRestoreSet(token + 1) } just Runs + every { metadataManager.onDeviceInitialization(token + 1) } just Runs every { data.close() } just Runs From ee581ee652e38a61ca44a26a76954cf565d1eb19 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 27 Feb 2024 14:51:02 -0300 Subject: [PATCH 18/22] Initialize backup, when enabling it For ApkBackup, we need to be initialized. If the system starts with app backup off, we would not initialize which would lead to issues when backing up the APKs. --- .../seedvault/e2e/LargeBackupTestBase.kt | 6 ++++ .../java/com/stevesoltys/seedvault/App.kt | 2 +- .../seedvault/settings/SettingsActivity.kt | 14 +++++++++ .../seedvault/settings/SettingsFragment.kt | 9 +----- .../seedvault/settings/SettingsViewModel.kt | 31 +++++++++++++++++++ .../seedvault/ui/BackupActivity.kt | 12 ++++--- 6 files changed, 60 insertions(+), 14 deletions(-) diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt index 3a49d35e..09b88e3f 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt @@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.e2e import android.content.pm.PackageInfo import android.os.ParcelFileDescriptor +import androidx.test.uiautomator.Until import com.stevesoltys.seedvault.e2e.io.BackupDataInputIntercept import com.stevesoltys.seedvault.e2e.io.InputStreamIntercept import com.stevesoltys.seedvault.e2e.screen.impl.BackupScreen @@ -44,6 +45,11 @@ internal interface LargeBackupTestBase : LargeTestBase { if (!backupManager.isBackupEnabled) { backupSwitch.click() waitUntilIdle() + + BackupScreen { + device.wait(Until.hasObject(initializingText), 10000) + device.wait(Until.gone(initializingText), 120000) + } } backupMenu.clickAndWaitForNewWindow() diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index 9391a801..a51b5263 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/App.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt @@ -57,7 +57,7 @@ open class App : Application() { factory { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) } factory { AppListRetriever(this@App, get(), get(), get()) } - viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get(), get()) } + viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) } viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) } viewModel { BackupStorageViewModel(this@App, get(), get(), get(), get()) } viewModel { RestoreStorageViewModel(this@App, get(), get()) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt index 11fc6b5e..3aecb911 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt @@ -10,6 +10,7 @@ import com.stevesoltys.seedvault.ui.RequireProvisioningActivity import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.recoverycode.ARG_FOR_NEW_CODE +import com.stevesoltys.seedvault.ui.storage.StorageCheckFragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -36,6 +37,19 @@ class SettingsActivity : RequireProvisioningActivity(), OnPreferenceStartFragmen if (intent?.action == ACTION_APP_STATUS_LIST) { showFragment(AppStatusFragment(), true) } + + // observe initialization and show/remove init fragment + // this can happen when enabling backup and storage wasn't initialized + viewModel.initEvent.observeEvent(this) { show -> + val tag = "INIT" + if (show) { + val title = getString(R.string.storage_check_fragment_backup_title) + showFragment(StorageCheckFragment.newInstance(title), true, tag) + } else { + val f = supportFragmentManager.findFragmentByTag(tag) + if (f != null && f.isVisible) supportFragmentManager.popBackStack() + } + } } @CallSuper diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt index 33fe15dc..d93d573d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt @@ -19,7 +19,6 @@ import androidx.preference.Preference import androidx.preference.Preference.OnPreferenceChangeListener import androidx.preference.PreferenceFragmentCompat import androidx.preference.TwoStatePreference -import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE import androidx.work.WorkInfo import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.permitDiskReads @@ -213,16 +212,10 @@ class SettingsFragment : PreferenceFragmentCompat() { else -> super.onOptionsItemSelected(item) } - // TODO this should really get moved out off the UI layer private fun trySetBackupEnabled(enabled: Boolean): Boolean { return try { backupManager.isBackupEnabled = enabled - if (enabled) { - viewModel.scheduleAppBackup(CANCEL_AND_REENQUEUE) - viewModel.enableCallLogBackup() - } else { - viewModel.cancelAppBackup() - } + viewModel.onBackupEnabled(enabled) backup.isChecked = enabled true } catch (e: RemoteException) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index b80ed331..99310110 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -37,6 +37,9 @@ import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.storage.StorageBackupJobService import com.stevesoltys.seedvault.storage.StorageBackupService import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP +import com.stevesoltys.seedvault.transport.backup.BackupInitializer +import com.stevesoltys.seedvault.ui.LiveEvent +import com.stevesoltys.seedvault.ui.MutableLiveEvent import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.worker.AppBackupWorker import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME @@ -60,6 +63,7 @@ internal class SettingsViewModel( private val appListRetriever: AppListRetriever, private val storageBackup: StorageBackup, private val backupManager: IBackupManager, + private val backupInitializer: BackupInitializer, ) : RequireProvisioningViewModel(app, settingsManager, keyManager) { private val contentResolver = app.contentResolver @@ -90,6 +94,9 @@ internal class SettingsViewModel( private val _filesSummary = MutableLiveData() internal val filesSummary: LiveData = _filesSummary + private val _initEvent = MutableLiveEvent() + val initEvent: LiveEvent = _initEvent + private val storageObserver = object : ContentObserver(null) { override fun onChange(selfChange: Boolean, uris: MutableCollection, flags: Int) { onStoragePropertiesChanged() @@ -230,6 +237,30 @@ internal class SettingsViewModel( } } + fun onBackupEnabled(enabled: Boolean) { + if (enabled) { + if (metadataManager.requiresInit) { + val onError: () -> Unit = { + viewModelScope.launch(Dispatchers.Main) { + val res = R.string.storage_check_fragment_backup_error + Toast.makeText(app, res, LENGTH_LONG).show() + } + } + viewModelScope.launch(Dispatchers.IO) { + backupInitializer.initialize(onError) { + _initEvent.postEvent(false) + scheduleAppBackup(CANCEL_AND_REENQUEUE) + } + _initEvent.postEvent(true) + } + } + // enable call log backups for existing installs (added end of 2020) + enableCallLogBackup() + } else { + cancelAppBackup() + } + } + /** * Ensures that the call log will be included in backups. * diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/BackupActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/BackupActivity.kt index 40d35fba..7bb8b6b6 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/BackupActivity.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/BackupActivity.kt @@ -17,11 +17,13 @@ abstract class BackupActivity : AppCompatActivity() { else -> super.onOptionsItemSelected(item) } - protected fun showFragment(f: Fragment, addToBackStack: Boolean = false) { - val fragmentTransaction = supportFragmentManager.beginTransaction() - .replace(R.id.fragment, f) - if (addToBackStack) fragmentTransaction.addToBackStack(null) - fragmentTransaction.commit() + protected fun showFragment(f: Fragment, addToBackStack: Boolean = false, tag: String? = null) { + supportFragmentManager.beginTransaction().apply { + if (tag == null) replace(R.id.fragment, f) + else replace(R.id.fragment, f, tag) + if (addToBackStack) addToBackStack(null) + commit() + } } } From 8489753d58e9cbc07f8b19e5ea919d4d14cf3bb8 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 6 Mar 2024 14:05:52 -0300 Subject: [PATCH 19/22] Address review feedback --- .../seedvault/metadata/MetadataManager.kt | 35 ++++++++----------- .../settings/ExpertSettingsFragment.kt | 3 +- .../seedvault/settings/SettingsActivity.kt | 4 +-- .../seedvault/settings/SettingsFragment.kt | 7 ++++ 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt index bd5b4f38..b97c85ef 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt @@ -125,21 +125,20 @@ internal class MetadataManager( val now = clock.time() metadata.time = now metadata.d2dBackup = settingsManager.d2dBackupsEnabled() - - if (metadata.packageMetadataMap.containsKey(packageName)) { - metadata.packageMetadataMap[packageName]!!.time = now - metadata.packageMetadataMap[packageName]!!.state = APK_AND_DATA - metadata.packageMetadataMap[packageName]!!.backupType = type - // don't override a previous K/V size, if there were no K/V changes - if (size != null) metadata.packageMetadataMap[packageName]!!.size = size - } else { - metadata.packageMetadataMap[packageName] = PackageMetadata( + metadata.packageMetadataMap.getOrPut(packageName) { + PackageMetadata( time = now, state = APK_AND_DATA, backupType = type, size = size, system = packageInfo.isSystemApp(), ) + }.apply { + time = now + state = APK_AND_DATA + backupType = type + // don't override a previous K/V size, if there were no K/V changes + if (size != null) this.size = size } } } @@ -159,18 +158,15 @@ internal class MetadataManager( backupType: BackupType? = null, ) { check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." } - val packageName = packageInfo.packageName modifyMetadata(metadataOutputStream) { - if (metadata.packageMetadataMap.containsKey(packageName)) { - metadata.packageMetadataMap[packageName]!!.state = packageState - } else { - metadata.packageMetadataMap[packageName] = PackageMetadata( + metadata.packageMetadataMap.getOrPut(packageInfo.packageName) { + PackageMetadata( time = 0L, state = packageState, backupType = backupType, system = packageInfo.isSystemApp() ) - } + }.state = packageState } } @@ -186,16 +182,13 @@ internal class MetadataManager( packageInfo: PackageInfo, packageState: PackageState, ) = modifyCachedMetadata { - val packageName = packageInfo.packageName - if (metadata.packageMetadataMap.containsKey(packageName)) { - metadata.packageMetadataMap[packageName]!!.state = packageState - } else { - metadata.packageMetadataMap[packageName] = PackageMetadata( + metadata.packageMetadataMap.getOrPut(packageInfo.packageName) { + PackageMetadata( time = 0L, state = packageState, system = packageInfo.isSystemApp(), ) - } + }.state = packageState } /** diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt index 269f90cb..27a822ed 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt @@ -5,6 +5,7 @@ import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.SwitchPreferenceCompat +import com.google.android.mms.ContentType.TEXT_PLAIN import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.transport.backup.PackageService @@ -17,7 +18,7 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() { private val packageService: PackageService by inject() private val createFileLauncher = - registerForActivityResult(CreateDocument("text/plain")) { uri -> + registerForActivityResult(CreateDocument(TEXT_PLAIN)) { uri -> viewModel.onLogcatUriReceived(uri) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt index 3aecb911..0e224c65 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt @@ -46,8 +46,8 @@ class SettingsActivity : RequireProvisioningActivity(), OnPreferenceStartFragmen val title = getString(R.string.storage_check_fragment_backup_title) showFragment(StorageCheckFragment.newInstance(title), true, tag) } else { - val f = supportFragmentManager.findFragmentByTag(tag) - if (f != null && f.isVisible) supportFragmentManager.popBackStack() + val fragment = supportFragmentManager.findFragmentByTag(tag) + if (fragment?.isVisible == true) supportFragmentManager.popBackStack() } } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt index d93d573d..e76bcf82 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt @@ -263,6 +263,13 @@ class SettingsFragment : PreferenceFragmentCompat() { } } + /** + * Sets the summary for scheduling which is information about when the next backup is scheduled. + * + * It could be that it shows the backup as running, + * gives an estimate about when the next run will be or + * says that nothing is scheduled which can happen when backup destination is on flash drive. + */ private fun setAppBackupSchedulingSummary(workInfo: WorkInfo?) { if (storage?.isUsb == true) { backupScheduling.summary = getString(R.string.settings_backup_status_next_backup_usb) From 0b021e3b48a236210d82763cebf779cac4259629 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 6 Mar 2024 16:15:01 -0300 Subject: [PATCH 20/22] Try to close system dialogs of emulator --- app/development/scripts/provision_emulator.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/development/scripts/provision_emulator.sh b/app/development/scripts/provision_emulator.sh index 284e7082..030eff7a 100755 --- a/app/development/scripts/provision_emulator.sh +++ b/app/development/scripts/provision_emulator.sh @@ -97,4 +97,7 @@ $ADB shell mkdir -p /sdcard/seedvault_baseline $ADB shell tar xzf /sdcard/backup.tar.gz --directory=/sdcard/seedvault_baseline $ADB shell rm /sdcard/backup.tar.gz +# sometimes a system dialog (e.g. launcher stopped) is showing and taking focus +$ADB shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS + echo "Emulator '$EMULATOR_NAME' has been provisioned with Seedvault!" From f7730d3034560d52de8c79479d44ae9ca0913d90 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 13 Mar 2024 09:55:46 -0300 Subject: [PATCH 21/22] Report total number of user apps when showing final notification Before, we showed the number of apps we requested the backup for which in case of non-d2d may be much lower than the number of installed apps. In the future we may decide to also include certain system apps in that count. --- .../ui/notification/NotificationBackupObserver.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt index 5f5eaea4..41498d89 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt @@ -10,6 +10,7 @@ import android.util.Log.isLoggable import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.metadata.MetadataManager +import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.worker.BackupRequester import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -24,6 +25,7 @@ internal class NotificationBackupObserver( private val nm: BackupNotificationManager by inject() private val metadataManager: MetadataManager by inject() + private val packageService: PackageService by inject() private var currentPackage: String? = null private var numPackages: Int = 0 private var pmCounted: Boolean = false @@ -81,7 +83,13 @@ internal class NotificationBackupObserver( val success = status == 0 val numBackedUp = if (success) metadataManager.getPackagesNumBackedUp() else null val size = if (success) metadataManager.getPackagesBackupSize() else 0L - nm.onBackupFinished(success, numBackedUp, requestedPackages, size) + val total = try { + packageService.allUserPackages.size + } catch (e: Exception) { + Log.e(TAG, "Error getting number of all user packages: ", e) + requestedPackages + } + nm.onBackupFinished(success, numBackedUp, total, size) } } From baef15b2bc6a1858f1065461b925e19321ab5138 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 19 Mar 2024 11:07:55 -0300 Subject: [PATCH 22/22] Do live-counting of backed up apps for success notification Previously, we asked the MetadataManager which also includes historic data and may provide misleading totals. --- .../seedvault/metadata/MetadataManager.kt | 13 ------- .../NotificationBackupObserver.kt | 34 +++++++++++++------ 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt index b97c85ef..6c09ac8f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt @@ -14,7 +14,6 @@ import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.encodeBase64 import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA -import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.transport.backup.isSystemApp import java.io.FileNotFoundException @@ -268,18 +267,6 @@ internal class MetadataManager( return metadata.packageMetadataMap[packageName]?.copy() } - @Synchronized - fun getPackagesNumBackedUp(): Int { - // FIXME we are under-reporting packages here, - // because we have no way to also include upgraded system apps - return metadata.packageMetadataMap.filter { (_, packageMetadata) -> - !packageMetadata.system && ( // ignore system apps - packageMetadata.state == APK_AND_DATA || // either full success - packageMetadata.state == NO_DATA // or apps that simply had no data - ) - }.count() - } - @Synchronized fun getPackagesBackupSize(): Long { return metadata.packageMetadataMap.values.sumOf { it.size ?: 0L } diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt index 41498d89..30d93441 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt @@ -3,6 +3,7 @@ package com.stevesoltys.seedvault.ui.notification import android.app.backup.BackupProgress import android.app.backup.IBackupObserver import android.content.Context +import android.content.pm.ApplicationInfo.FLAG_SYSTEM import android.content.pm.PackageManager.NameNotFoundException import android.util.Log import android.util.Log.INFO @@ -28,6 +29,7 @@ internal class NotificationBackupObserver( private val packageService: PackageService by inject() private var currentPackage: String? = null private var numPackages: Int = 0 + private var numPackagesToReport: Int = 0 private var pmCounted: Boolean = false init { @@ -64,6 +66,26 @@ internal class NotificationBackupObserver( if (isLoggable(TAG, INFO)) { Log.i(TAG, "Completed. Target: $target, status: $status") } + // prevent double counting of @pm@ which gets backed up with each requested chunk + if (target == MAGIC_PACKAGE_MANAGER) { + if (!pmCounted) { + numPackages += 1 + pmCounted = true + } + } else { + numPackages += 1 + } + // count package if success and not a system app + if (status == 0 && target != null && target != MAGIC_PACKAGE_MANAGER) try { + val appInfo = context.packageManager.getApplicationInfo(target, 0) + // exclude system apps from final count for now + if (appInfo.flags and FLAG_SYSTEM == 0) { + numPackagesToReport += 1 + } + } catch (e: Exception) { + // should only happen for MAGIC_PACKAGE_MANAGER, but better save than sorry + Log.e(TAG, "Error getting ApplicationInfo: ", e) + } // often [onResult] gets called right away without any [onUpdate] call showProgressNotification(target) } @@ -81,7 +103,6 @@ internal class NotificationBackupObserver( Log.i(TAG, "Backup finished $numPackages/$requestedPackages. Status: $status") } val success = status == 0 - val numBackedUp = if (success) metadataManager.getPackagesNumBackedUp() else null val size = if (success) metadataManager.getPackagesBackupSize() else 0L val total = try { packageService.allUserPackages.size @@ -89,7 +110,7 @@ internal class NotificationBackupObserver( Log.e(TAG, "Error getting number of all user packages: ", e) requestedPackages } - nm.onBackupFinished(success, numBackedUp, total, size) + nm.onBackupFinished(success, numPackagesToReport, total, size) } } @@ -107,15 +128,6 @@ internal class NotificationBackupObserver( } else { context.getString(R.string.backup_section_system) } - // prevent double counting of @pm@ which gets backed up with each requested chunk - if (packageName == MAGIC_PACKAGE_MANAGER) { - if (!pmCounted) { - numPackages += 1 - pmCounted = true - } - } else { - numPackages += 1 - } Log.i(TAG, "$numPackages/$requestedPackages - $appName ($packageName)") nm.onBackupUpdate(name, numPackages, requestedPackages) }