diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bf8aa1b8..372144ca 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: @@ -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 }} 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!" 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..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() @@ -179,7 +185,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/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..61155956 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/UsbIntentReceiver.kt @@ -20,15 +20,13 @@ 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 +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 { @@ -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, reschedule = false) } } 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..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,9 +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.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 +33,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 = "") @@ -61,14 +58,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) } } @@ -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, @@ -143,21 +124,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 } } } @@ -177,24 +157,69 @@ 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 + } + } + + /** + * 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 { + metadata.packageMetadataMap.getOrPut(packageInfo.packageName) { + PackageMetadata( + time = 0L, + state = packageState, + system = packageInfo.isSystemApp(), + ) + }.state = packageState + } + + /** + * 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) @@ -242,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/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/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/restore/install/ApkRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt index a553ce82..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 @@ -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 @@ -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/settings/ExpertSettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt index b1281325..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 @@ -16,10 +17,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 { @@ -44,8 +45,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/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/SettingsActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt index 11fc6b5e..0e224c65 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 fragment = supportFragmentManager.findFragmentByTag(tag) + if (fragment?.isVisible == true) 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 8115c533..e76bcf82 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,14 @@ import androidx.preference.Preference import androidx.preference.Preference.OnPreferenceChangeListener import androidx.preference.PreferenceFragmentCompat import androidx.preference.TwoStatePreference +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 +41,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,12 +124,14 @@ class SettingsFragment : PreferenceFragmentCompat() { return@OnPreferenceChangeListener false } backupStatus = findPreference("backup_status")!! + backupScheduling = findPreference("backup_scheduling")!! 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.cancelFilesBackup() return@OnPreferenceChangeListener true } onEnablingStorageBackup() @@ -142,6 +147,10 @@ class SettingsFragment : PreferenceFragmentCompat() { viewModel.lastBackupTime.observe(viewLifecycleOwner) { time -> setAppBackupStatusSummary(time) } + viewModel.appBackupWorkInfo.observe(viewLifecycleOwner) { workInfo -> + viewModel.onWorkerStateChanged() + setAppBackupSchedulingSummary(workInfo) + } val backupFiles: Preference = findPreference("backup_files")!! viewModel.filesSummary.observe(viewLifecycleOwner) { summary -> @@ -159,6 +168,8 @@ class SettingsFragment : PreferenceFragmentCompat() { setBackupEnabledState() setBackupLocationSummary() setAutoRestoreState() + setAppBackupStatusSummary(viewModel.lastBackupTime.value) + setAppBackupSchedulingSummary(viewModel.appBackupWorkInfo.value) } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { @@ -204,7 +215,7 @@ class SettingsFragment : PreferenceFragmentCompat() { private fun trySetBackupEnabled(enabled: Boolean): Boolean { return try { backupManager.isBackupEnabled = enabled - if (enabled) viewModel.enableCallLogBackup() + viewModel.onBackupEnabled(enabled) backup.isChecked = enabled true } catch (e: RemoteException) { @@ -244,10 +255,48 @@ 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?) { + if (lastBackupInMillis != null) { + // set time of last backup + val lastBackup = lastBackupInMillis.toRelativeTime(requireContext()) + backupStatus.summary = getString(R.string.settings_backup_status_summary, lastBackup) + } + } + + /** + * 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) + 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) + } + } } private fun onEnablingStorageBackup() { @@ -268,7 +317,7 @@ class SettingsFragment : PreferenceFragmentCompat() { LENGTH_LONG ).show() } - viewModel.enableStorageBackup() + viewModel.scheduleFilesBackup() backupStorage.isChecked = true dialog.dismiss() } 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..786f5781 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. @@ -134,13 +146,24 @@ 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 { 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) @@ -186,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/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index f56faa2f..99310110 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -11,8 +11,8 @@ 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.os.UserHandle import android.provider.Settings import android.util.Log import android.widget.Toast @@ -22,10 +22,14 @@ 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 com.stevesoltys.seedvault.BackupWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE +import androidx.work.WorkInfo +import androidx.work.WorkManager import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.metadata.MetadataManager @@ -33,9 +37,12 @@ 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.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.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 @@ -52,16 +59,17 @@ 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, private val backupManager: IBackupManager, + private val backupInitializer: BackupInitializer, ) : RequireProvisioningViewModel(app, settingsManager, keyManager) { private val contentResolver = app.contentResolver private val connectivityManager: ConnectivityManager? = app.getSystemService(ConnectivityManager::class.java) + private val workManager = WorkManager.getInstance(app) override val isRestoreOperation = false @@ -69,6 +77,10 @@ internal class SettingsViewModel( val backupPossible: LiveData = mBackupPossible internal val lastBackupTime = metadataManager.lastBackupTime + internal val appBackupWorkInfo = + workManager.getWorkInfosForUniqueWorkLiveData(UNIQUE_WORK_NAME).map { + it.getOrNull(0) + } private val mAppStatusList = lastBackupTime.switchMap { // updates app list when lastBackupTime changes @@ -82,21 +94,24 @@ 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) { - 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() } } @@ -111,13 +126,39 @@ 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 (isUsb: ${storage.isUsb}") + if (storage.isUsb) { + // disable storage backup if new storage is on USB + cancelAppBackup() + cancelFilesBackup() + } else { + // enable it, just in case the previous storage was on USB, + // also to update the network requirement of the new storage + scheduleAppBackup(CANCEL_AND_REENQUEUE) + scheduleFilesBackup() + } + 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 + + Log.d(TAG, "onStoragePropertiesChanged") // register storage observer try { contentResolver.unregisterContentObserver(storageObserver) @@ -139,19 +180,8 @@ internal class SettingsViewModel( connectivityManager?.registerNetworkCallback(request, networkCallback) 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) - } + // update whether we can do backups right now or not + onWorkerStateChanged() } override fun onCleared() { @@ -163,25 +193,27 @@ 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 i.putExtra(EXTRA_START_APP_BACKUP, true) startForegroundService(app, i) } else { - requestBackup(app) + val isUsb = settingsManager.getStorage()?.isUsb ?: false + AppBackupWorker.scheduleNow(app, reschedule = !isUsb) } } } 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)) @@ -205,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. * @@ -223,20 +279,33 @@ internal class SettingsViewModel( return keyManager.hasMainKey() } - fun enableStorageBackup() { + fun scheduleAppBackup(existingWorkPolicy: ExistingPeriodicWorkPolicy) { 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 && backupManager.isBackupEnabled) { + AppBackupWorker.schedule(app, settingsManager, existingWorkPolicy) + } } - fun disableStorageBackup() { + 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), + networkType = if (storage.requiresNetwork) NETWORK_TYPE_UNMETERED + else NETWORK_TYPE_NONE, + deviceIdle = false, + charging = true + ) + } + } + + fun cancelAppBackup() { + AppBackupWorker.unschedule(app) + } + + fun cancelFilesBackup() { BackupJobService.cancelJob(app) } @@ -264,13 +333,4 @@ internal class SettingsViewModel( Toast.makeText(app, str, LENGTH_LONG).show() } - fun onD2dChanged(enabled: Boolean) { - backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), !enabled) - if (enabled) { - BackupWorker.schedule(app) - } else { - BackupWorker.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..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,7 +1,8 @@ package com.stevesoltys.seedvault.storage import android.content.Intent -import com.stevesoltys.seedvault.transport.requestBackup +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 import org.calyxos.backup.storage.api.StorageBackup @@ -23,6 +24,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() { @@ -32,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 { @@ -40,7 +43,8 @@ internal class StorageBackupService : BackupService() { override fun onBackupFinished(intent: Intent, success: Boolean) { if (intent.getBooleanExtra(EXTRA_START_APP_BACKUP, false)) { - requestBackup(applicationContext) + val isUsb = settingsManager.getStorage()?.isUsb ?: false + AppBackupWorker.scheduleNow(applicationContext, reschedule = !isUsb) } } } 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/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/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt index f1dde3b1..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 @@ -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, @@ -91,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) } /** @@ -120,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() @@ -156,13 +148,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() @@ -233,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") @@ -369,9 +352,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 +362,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 +390,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) @@ -503,7 +416,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/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 bf7d3272..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( @@ -13,14 +14,6 @@ val backupModule = module { plugin = get() ) } - single { - ApkBackup( - pm = androidContext().packageManager, - crypto = get(), - settingsManager = get(), - metadataManager = get() - ) - } single { KvDbManagerImpl(androidContext()) } single { KVBackup( @@ -45,7 +38,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/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/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() + } } } 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..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 @@ -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,14 +27,13 @@ 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_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_OBSERVER = 1 private const val NOTIFICATION_ID_SUCCESS = 2 private const val NOTIFICATION_ID_ERROR = 3 private const val NOTIFICATION_ID_RESTORE_ERROR = 4 @@ -50,14 +50,6 @@ internal class BackupNotificationManager(private val context: Context) { 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) @@ -84,95 +76,70 @@ 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) + updateBackupNotification(text, transferred, expected) } /** - * 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 text = context.getString(R.string.notification_apk_not_backed_up) + updateBackupNotification(text) + } - 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 - } + /** + * Call after [onApkBackup] or [onAppsNotBackedUp] were called. + */ + fun onApkBackupDone() { + nm.cancel(NOTIFICATION_ID_OBSERVER) + } + + /** + * Call this right after starting a backup. + */ + fun onBackupStarted(expectedPackages: Int) { + updateBackupNotification( + text = "", // 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 - updateBackupNotification( - infoText = app, - transferred = min(transferred + addend, expected + addend), - expected = expected + addend - ) + fun onBackupUpdate(app: CharSequence, transferred: Int, total: Int) { + updateBackupNotification(app, min(transferred, total), total) } private fun updateBackupNotification( - infoText: CharSequence, - transferred: Int, - expected: Int, + text: CharSequence, + transferred: Int = 0, + expected: Int = 0, ) { - @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) - 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() { @@ -197,11 +164,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,20 +190,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 { - 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") 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..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 @@ -10,8 +11,8 @@ 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.transport.backup.ExpectedAppTotals +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 @@ -21,18 +22,20 @@ 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() private val metadataManager: MetadataManager by inject() + 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 { // 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) } /** @@ -63,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) } @@ -80,9 +103,14 @@ 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 - nm.onBackupFinished(success, numBackedUp, 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, numPackagesToReport, total, size) } } @@ -95,13 +123,13 @@ 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) + Log.i(TAG, "$numPackages/$requestedPackages - $appName ($packageName)") + nm.onBackupUpdate(name, numPackages, requestedPackages) } private fun getAppName(packageId: String): CharSequence = getAppName(context, packageId) 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..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) { @@ -102,17 +100,16 @@ 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() 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 4595468b..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,30 +1,30 @@ 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.transport.TRANSPORT_ID -import com.stevesoltys.seedvault.transport.backup.BackupCoordinator -import com.stevesoltys.seedvault.transport.requestBackup +import com.stevesoltys.seedvault.storage.StorageBackupJobService +import com.stevesoltys.seedvault.transport.backup.BackupInitializer +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 internal class BackupStorageViewModel( private val app: Application, private val backupManager: IBackupManager, - private val backupCoordinator: BackupCoordinator, + private val backupInitializer: BackupInitializer, private val storageBackup: StorageBackup, settingsManager: SettingsManager, ) : StorageViewModel(app, settingsManager) { @@ -33,19 +33,39 @@ 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 { // 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) @@ -54,34 +74,29 @@ internal class BackupStorageViewModel( } } - @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) { - requestBackup(app) - } - } else { - // notify the UI that the location was invalid - onInitializationError() + private fun scheduleBackupWorkers() { + val storage = settingsManager.getStorage() ?: error("no storage available") + if (!storage.isUsb) { + if (backupManager.isBackupEnabled) { + AppBackupWorker.schedule(app, settingsManager, CANCEL_AND_REENQUEUE) } + 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) + } + private fun onInitializationError() { val errorMsg = app.getString(R.string.storage_check_fragment_backup_error) mLocationChecked.postEvent(LocationResult(errorMsg)) 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..be1942c1 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt @@ -0,0 +1,132 @@ +/* + * 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 kotlinx.coroutines.delay +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 { + 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() + } + } + + /** + * 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 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/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..64a1cbed --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt @@ -0,0 +1,152 @@ +/* + * 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.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.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.settings.SettingsManager +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 + internal const val UNIQUE_WORK_NAME = "com.stevesoltys.seedvault.APP_BACKUP" + private const val TAG_RESCHEDULE = "com.stevesoltys.seedvault.TAG_RESCHEDULE" + + /** + * (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 = 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, " workRequest: ${workRequest.id}") + workManager.enqueueUniquePeriodicWork(UNIQUE_WORK_NAME, existingWorkPolicy, workRequest) + } + + fun scheduleNow(context: Context, reschedule: Boolean) { + val workRequest = OneTimeWorkRequestBuilder() + .setExpedited(RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .apply { if (reschedule) addTag(TAG_RESCHEDULE) } + .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 settingsManager: SettingsManager by inject() + private val apkBackupManager: ApkBackupManager by inject() + 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 { + 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() + if (tags.contains(TAG_RESCHEDULE) && backupRequester.isBackupEnabled) { + // needs to use CANCEL_AND_REENQUEUE otherwise it doesn't get scheduled + schedule(applicationContext, settingsManager, CANCEL_AND_REENQUEUE) + } + } + } + + private suspend fun doBackup(): Result { + var result: Result = Result.success() + try { + 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... (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 + } + + 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 92% 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 79c79ba9..02d4cc3b 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,12 +35,13 @@ internal class BackupRequester( val packageService: PackageService, ) : KoinComponent { + val isBackupEnabled: Boolean get() = backupManager.isBackupEnabled + private val packages = packageService.eligiblePackages private val observer = NotificationBackupObserver( context = context, backupRequester = this, requestedPackages = packages.size, - appTotals = packageService.expectedAppTotals, ) private val monitor = BackupMonitor() @@ -100,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 } 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..dce45be2 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt @@ -0,0 +1,38 @@ +/* + * 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 { + factory { + BackupRequester( + context = androidContext(), + backupManager = get(), + packageService = get(), + ) + } + 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/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 7e23529b..801b15d4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,6 +30,11 @@ Disable app backup Backup status 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) @@ -46,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. @@ -122,6 +136,8 @@ 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/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 @@ + + + + + + + + + + + + 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() } 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..62300e76 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 @@ -98,7 +100,7 @@ class MetadataManagerTest { expectReadFromCache() expectModifyMetadata(initialMetadata) - manager.onDeviceInitialization(token, storageOutputStream) + manager.onDeviceInitialization(token) assertEquals(token, manager.getBackupToken()) assertEquals(0L, manager.getLastBackupTime()) @@ -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..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 @@ -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, @@ -75,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 @@ -142,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 @@ -157,16 +148,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 +263,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)) } @@ -304,6 +291,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( @@ -353,6 +341,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( @@ -380,180 +369,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..f12f4def --- /dev/null +++ b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt @@ -0,0 +1,237 @@ +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.andThenJust +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.IOException +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() + } + } + + @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() + } + + 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())