Merge pull request #628 from grote/583-scheduling

Move to our own scheduling
This commit is contained in:
Torsten Grote 2024-03-26 12:23:49 -03:00 committed by GitHub
commit 6caa01f8c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1529 additions and 799 deletions

View file

@ -18,7 +18,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
android_target: [ 33, 34 ] android_target: [ 34 ]
emulator_type: [ aosp_atd ] emulator_type: [ aosp_atd ]
d2d_backup_test: [ true, false ] d2d_backup_test: [ true, false ]
steps: steps:
@ -40,7 +40,7 @@ jobs:
- name: Run tests - name: Run tests
uses: Wandalen/wretry.action@v1.3.0 uses: Wandalen/wretry.action@v1.3.0
with: with:
attempt_limit: 3 attempt_limit: 1
action: reactivecircus/android-emulator-runner@v2 action: reactivecircus/android-emulator-runner@v2
with: | with: |
api-level: ${{ matrix.android_target }} api-level: ${{ matrix.android_target }}

View file

@ -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 tar xzf /sdcard/backup.tar.gz --directory=/sdcard/seedvault_baseline
$ADB shell rm /sdcard/backup.tar.gz $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!" echo "Emulator '$EMULATOR_NAME' has been provisioned with Seedvault!"

View file

@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.e2e
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import androidx.test.uiautomator.Until
import com.stevesoltys.seedvault.e2e.io.BackupDataInputIntercept import com.stevesoltys.seedvault.e2e.io.BackupDataInputIntercept
import com.stevesoltys.seedvault.e2e.io.InputStreamIntercept import com.stevesoltys.seedvault.e2e.io.InputStreamIntercept
import com.stevesoltys.seedvault.e2e.screen.impl.BackupScreen import com.stevesoltys.seedvault.e2e.screen.impl.BackupScreen
@ -44,6 +45,11 @@ internal interface LargeBackupTestBase : LargeTestBase {
if (!backupManager.isBackupEnabled) { if (!backupManager.isBackupEnabled) {
backupSwitch.click() backupSwitch.click()
waitUntilIdle() waitUntilIdle()
BackupScreen {
device.wait(Until.hasObject(initializingText), 10000)
device.wait(Until.gone(initializingText), 120000)
}
} }
backupMenu.clickAndWaitForNewWindow() backupMenu.clickAndWaitForNewWindow()
@ -179,7 +185,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
clearMocks(spyBackupNotificationManager) clearMocks(spyBackupNotificationManager)
every { every {
spyBackupNotificationManager.onBackupFinished(any(), any(), any()) spyBackupNotificationManager.onBackupFinished(any(), any(), any(), any())
} answers { } answers {
val success = firstArg<Boolean>() val success = firstArg<Boolean>()
assert(success) { "Backup failed." } assert(success) { "Backup failed." }

View file

@ -156,6 +156,11 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- Used by Workmanager to schedule our workers -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<!-- Used to start actual BackupService depending on scheduling criteria --> <!-- Used to start actual BackupService depending on scheduling criteria -->
<service <service
android:name=".storage.StorageBackupJobService" android:name=".storage.StorageBackupJobService"

View file

@ -9,7 +9,11 @@ import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build import android.os.Build
import android.os.ServiceManager.getService import android.os.ServiceManager.getService
import android.os.StrictMode import android.os.StrictMode
import android.os.UserHandle
import android.os.UserManager import android.os.UserManager
import android.provider.Settings
import androidx.work.WorkManager
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
import com.stevesoltys.seedvault.crypto.cryptoModule import com.stevesoltys.seedvault.crypto.cryptoModule
import com.stevesoltys.seedvault.header.headerModule import com.stevesoltys.seedvault.header.headerModule
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
@ -28,6 +32,8 @@ import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel
import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel
import com.stevesoltys.seedvault.worker.AppBackupWorker
import com.stevesoltys.seedvault.worker.workerModule
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
@ -42,6 +48,8 @@ import org.koin.dsl.module
*/ */
open class App : Application() { open class App : Application() {
open val isTest: Boolean = false
private val appModule = module { private val appModule = module {
single { SettingsManager(this@App) } single { SettingsManager(this@App) }
single { BackupNotificationManager(this@App) } single { BackupNotificationManager(this@App) }
@ -78,6 +86,7 @@ open class App : Application() {
permitDiskReads { permitDiskReads {
migrateTokenFromMetadataToSettingsManager() migrateTokenFromMetadataToSettingsManager()
} }
if (!isTest) migrateToOwnScheduling()
} }
protected open fun startKoin() = startKoin { protected open fun startKoin() = startKoin {
@ -95,11 +104,13 @@ open class App : Application() {
restoreModule, restoreModule,
installModule, installModule,
storageModule, storageModule,
workerModule,
appModule appModule
) )
private val settingsManager: SettingsManager by inject() private val settingsManager: SettingsManager by inject()
private val metadataManager: MetadataManager by inject() private val metadataManager: MetadataManager by inject()
private val backupManager: IBackupManager by inject()
/** /**
* The responsibility for the current token was moved to the [SettingsManager] * The responsibility for the current token was moved to the [SettingsManager]
@ -115,6 +126,25 @@ open class App : Application() {
} }
} }
/**
* Disables the framework scheduling in favor of our own.
* Introduced in the first half of 2024 and can be removed after a suitable migration period.
*/
protected open fun migrateToOwnScheduling() {
if (!isFrameworkSchedulingEnabled()) return // already on own scheduling
backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), false)
if (backupManager.isBackupEnabled) {
AppBackupWorker.schedule(applicationContext, settingsManager, UPDATE)
}
// cancel old D2D worker
WorkManager.getInstance(this).cancelUniqueWork("APP_BACKUP")
}
private fun isFrameworkSchedulingEnabled(): Boolean = Settings.Secure.getInt(
contentResolver, Settings.Secure.BACKUP_SCHEDULING_ENABLED, 1
) == 1 // 1 means enabled which is the default
} }
const val MAGIC_PACKAGE_MANAGER = PACKAGE_MANAGER_SENTINEL const val MAGIC_PACKAGE_MANAGER = PACKAGE_MANAGER_SENTINEL

View file

@ -1,57 +0,0 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.stevesoltys.seedvault.transport.requestBackup
import java.util.concurrent.TimeUnit
class BackupWorker(
appContext: Context,
workerParams: WorkerParameters,
) : Worker(appContext, workerParams) {
companion object {
private const val UNIQUE_WORK_NAME = "APP_BACKUP"
fun schedule(appContext: Context) {
val backupConstraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresCharging(true)
.build()
val backupWorkRequest = PeriodicWorkRequestBuilder<BackupWorker>(
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()
}
}

View file

@ -20,15 +20,13 @@ import com.stevesoltys.seedvault.settings.FlashDrive
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.storage.StorageBackupService import com.stevesoltys.seedvault.storage.StorageBackupService
import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP 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.ui.storage.AUTHORITY_STORAGE
import com.stevesoltys.seedvault.worker.AppBackupWorker
import org.koin.core.context.GlobalContext.get 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 val TAG = UsbIntentReceiver::class.java.simpleName
private const val HOURS_AUTO_BACKUP: Long = 24
class UsbIntentReceiver : UsbMonitor() { class UsbIntentReceiver : UsbMonitor() {
// using KoinComponent would crash robolectric tests :( // using KoinComponent would crash robolectric tests :(
@ -43,11 +41,13 @@ class UsbIntentReceiver : UsbMonitor() {
return if (savedFlashDrive == attachedFlashDrive) { return if (savedFlashDrive == attachedFlashDrive) {
Log.d(TAG, "Matches stored device, checking backup time...") Log.d(TAG, "Matches stored device, checking backup time...")
val backupMillis = System.currentTimeMillis() - metadataManager.getLastBackupTime() val backupMillis = System.currentTimeMillis() - metadataManager.getLastBackupTime()
if (backupMillis >= HOURS.toMillis(HOURS_AUTO_BACKUP)) { if (backupMillis >= settingsManager.backupFrequencyInMillis) {
Log.d(TAG, "Last backup older than 24 hours, requesting a backup...") Log.d(TAG, "Last backup older than it should be, requesting a backup...")
Log.d(TAG, " ${Date(metadataManager.getLastBackupTime())}")
true true
} else { } else {
Log.d(TAG, "We have a recent backup, not requesting a new one.") Log.d(TAG, "We have a recent backup, not requesting a new one.")
Log.d(TAG, " ${Date(metadataManager.getLastBackupTime())}")
false false
} }
} else { } else {
@ -63,9 +63,7 @@ class UsbIntentReceiver : UsbMonitor() {
i.putExtra(EXTRA_START_APP_BACKUP, true) i.putExtra(EXTRA_START_APP_BACKUP, true)
startForegroundService(context, i) startForegroundService(context, i)
} else { } else {
Thread { AppBackupWorker.scheduleNow(context, reschedule = false)
requestBackup(context)
}.start()
} }
} }

View file

@ -14,9 +14,6 @@ import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.encodeBase64 import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA 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.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.isSystemApp import com.stevesoltys.seedvault.transport.backup.isSystemApp
import java.io.FileNotFoundException import java.io.FileNotFoundException
@ -36,7 +33,7 @@ internal class MetadataManager(
private val crypto: Crypto, private val crypto: Crypto,
private val metadataWriter: MetadataWriter, private val metadataWriter: MetadataWriter,
private val metadataReader: MetadataReader, private val metadataReader: MetadataReader,
private val settingsManager: SettingsManager private val settingsManager: SettingsManager,
) { ) {
private val uninitializedMetadata = BackupMetadata(token = 0L, salt = "") private val uninitializedMetadata = BackupMetadata(token = 0L, salt = "")
@ -61,14 +58,15 @@ internal class MetadataManager(
/** /**
* Call this when initializing a new device. * Call this when initializing a new device.
* *
* Existing [BackupMetadata] will be cleared, use the given new token, * Existing [BackupMetadata] will be cleared
* and written encrypted to the given [OutputStream] as well as the internal cache. * and new metadata with the given [token] will be written to the internal cache
* with a fresh salt.
*/ */
@Synchronized @Synchronized
@Throws(IOException::class) @Throws(IOException::class)
fun onDeviceInitialization(token: Long, metadataOutputStream: OutputStream) { fun onDeviceInitialization(token: Long) {
val salt = crypto.getRandomBytes(METADATA_SALT_SIZE).encodeBase64() val salt = crypto.getRandomBytes(METADATA_SALT_SIZE).encodeBase64()
modifyMetadata(metadataOutputStream) { modifyCachedMetadata {
metadata = BackupMetadata(token = token, salt = salt) 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. * Call this after a package's APK has been backed up successfully.
* *
* It updates the packages' metadata * It updates the packages' metadata to the internal cache.
* and writes it encrypted to the given [OutputStream] as well as the internal cache. * You still need to call [uploadMetadata] to persist all local modifications.
*
* Closing the [OutputStream] is the responsibility of the caller.
*/ */
@Synchronized @Synchronized
@Throws(IOException::class) @Throws(IOException::class)
fun onApkBackedUp( fun onApkBackedUp(
packageInfo: PackageInfo, packageInfo: PackageInfo,
packageMetadata: PackageMetadata, packageMetadata: PackageMetadata,
metadataOutputStream: OutputStream,
) { ) {
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
metadata.packageMetadataMap[packageName]?.let { metadata.packageMetadataMap[packageName]?.let {
check(packageMetadata.version != null) { check(packageMetadata.version != null) {
"APK backup returned 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] val oldPackageMetadata = metadata.packageMetadataMap[packageName]
?: PackageMetadata() ?: PackageMetadata()
// only allow state change if backup of this package is not allowed, modifyCachedMetadata {
// 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) {
metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy( metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy(
state = newState,
system = packageInfo.isSystemApp(), system = packageInfo.isSystemApp(),
version = packageMetadata.version, version = packageMetadata.version,
installer = packageMetadata.installer, installer = packageMetadata.installer,
@ -143,21 +124,20 @@ internal class MetadataManager(
val now = clock.time() val now = clock.time()
metadata.time = now metadata.time = now
metadata.d2dBackup = settingsManager.d2dBackupsEnabled() metadata.d2dBackup = settingsManager.d2dBackupsEnabled()
metadata.packageMetadataMap.getOrPut(packageName) {
if (metadata.packageMetadataMap.containsKey(packageName)) { PackageMetadata(
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(
time = now, time = now,
state = APK_AND_DATA, state = APK_AND_DATA,
backupType = type, backupType = type,
size = size, size = size,
system = packageInfo.isSystemApp(), 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, backupType: BackupType? = null,
) { ) {
check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." } check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." }
val packageName = packageInfo.packageName
modifyMetadata(metadataOutputStream) { modifyMetadata(metadataOutputStream) {
if (metadata.packageMetadataMap.containsKey(packageName)) { metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
metadata.packageMetadataMap[packageName]!!.state = packageState PackageMetadata(
} else {
metadata.packageMetadataMap[packageName] = PackageMetadata(
time = 0L, time = 0L,
state = packageState, state = packageState,
backupType = backupType, backupType = backupType,
system = packageInfo.isSystemApp() 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) @Throws(IOException::class)
private fun modifyMetadata(metadataOutputStream: OutputStream, modFun: () -> Unit) { 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 { try {
modFun.invoke() modFun.invoke()
metadataWriter.write(metadata, metadataOutputStream) metadataWriter.write(metadata, metadataOutputStream)
@ -242,18 +267,6 @@ internal class MetadataManager(
return metadata.packageMetadataMap[packageName]?.copy() 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 @Synchronized
fun getPackagesBackupSize(): Long { fun getPackagesBackupSize(): Long {
return metadata.packageMetadataMap.values.sumOf { it.size ?: 0L } return metadata.packageMetadataMap.values.sumOf { it.size ?: 0L }

View file

@ -35,22 +35,13 @@ internal class DocumentsProviderStoragePlugin(
override suspend fun startNewRestoreSet(token: Long) { override suspend fun startNewRestoreSet(token: Long) {
// reset current storage // reset current storage
storage.reset(token) storage.reset(token)
// get or create root backup dir
storage.rootBackupDir ?: throw IOException()
} }
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun initializeDevice() { override suspend fun initializeDevice() {
// wipe existing data
storage.getSetDir()?.deleteContents(context)
// reset storage without new token, so folders get recreated // reset storage without new token, so folders get recreated
// otherwise stale DocumentFiles will hang around // otherwise stale DocumentFiles will hang around
storage.reset(null) storage.reset(null)
// create backup folders
storage.currentSetDir ?: throw IOException()
} }
@Throws(IOException::class) @Throws(IOException::class)

View file

@ -39,7 +39,7 @@ import com.stevesoltys.seedvault.restore.install.isInstalled
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.storage.StorageRestoreService import com.stevesoltys.seedvault.storage.StorageRestoreService
import com.stevesoltys.seedvault.transport.TRANSPORT_ID 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.transport.restore.RestoreCoordinator
import com.stevesoltys.seedvault.ui.AppBackupState import com.stevesoltys.seedvault.ui.AppBackupState
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED import com.stevesoltys.seedvault.ui.AppBackupState.FAILED

View file

@ -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.IN_PROGRESS
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED 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.transport.backup.isSystemApp
import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash
import com.stevesoltys.seedvault.worker.getSignatures
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@ -38,14 +38,12 @@ internal class ApkRestore(
private val pm = context.packageManager private val pm = context.packageManager
@Suppress("BlockingMethodInNonBlockingContext")
fun restore(backup: RestorableBackup) = flow { 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 { 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. // Otherwise, it gets killed when we install it, terminating our restoration.
val isStorageProvider = it.key == storagePlugin.providerPackageName it.key != storagePlugin.providerPackageName
it.value.hasApk() && !isStorageProvider
} }
val total = packages.size val total = packages.size
var progress = 0 var progress = 0
@ -66,7 +64,11 @@ internal class ApkRestore(
// re-install individual packages and emit updates // re-install individual packages and emit updates
for ((packageName, metadata) in packages) { for ((packageName, metadata) in packages) {
try { try {
if (metadata.hasApk()) {
restore(this, backup, packageName, metadata, installResult) restore(this, backup, packageName, metadata, installResult)
} else {
emit(installResult.fail(packageName))
}
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error re-installing APK for $packageName.", e) Log.e(TAG, "Error re-installing APK for $packageName.", e)
emit(installResult.fail(packageName)) emit(installResult.fail(packageName))

View file

@ -5,6 +5,7 @@ import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat import androidx.preference.SwitchPreferenceCompat
import com.google.android.mms.ContentType.TEXT_PLAIN
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.transport.backup.PackageService
@ -16,8 +17,8 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() {
private val viewModel: SettingsViewModel by sharedViewModel() private val viewModel: SettingsViewModel by sharedViewModel()
private val packageService: PackageService by inject() private val packageService: PackageService by inject()
// TODO set mimeType when upgrading androidx lib private val createFileLauncher =
private val createFileLauncher = registerForActivityResult(CreateDocument()) { uri -> registerForActivityResult(CreateDocument(TEXT_PLAIN)) { uri ->
viewModel.onLogcatUriReceived(uri) viewModel.onLogcatUriReceived(uri)
} }
@ -44,8 +45,7 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() {
val d2dPreference = findPreference<SwitchPreferenceCompat>(PREF_KEY_D2D_BACKUPS) val d2dPreference = findPreference<SwitchPreferenceCompat>(PREF_KEY_D2D_BACKUPS)
d2dPreference?.setOnPreferenceChangeListener { _, newValue -> d2dPreference?.setOnPreferenceChangeListener { _, newValue ->
viewModel.onD2dChanged(newValue as Boolean) d2dPreference.isChecked = newValue as Boolean
d2dPreference.isChecked = newValue
// automatically enable unlimited quota when enabling D2D backups // automatically enable unlimited quota when enabling D2D backups
if (d2dPreference.isChecked) { if (d2dPreference.isChecked) {

View file

@ -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<PreferenceCategory>("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)
}
}
}

View file

@ -10,6 +10,7 @@ import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.recoverycode.ARG_FOR_NEW_CODE 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.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
@ -36,6 +37,19 @@ class SettingsActivity : RequireProvisioningActivity(), OnPreferenceStartFragmen
if (intent?.action == ACTION_APP_STATUS_LIST) { if (intent?.action == ACTION_APP_STATUS_LIST) {
showFragment(AppStatusFragment(), true) 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 @CallSuper

View file

@ -19,12 +19,14 @@ import androidx.preference.Preference
import androidx.preference.Preference.OnPreferenceChangeListener import androidx.preference.Preference.OnPreferenceChangeListener
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.TwoStatePreference import androidx.preference.TwoStatePreference
import androidx.work.WorkInfo
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.restore.RestoreActivity import com.stevesoltys.seedvault.restore.RestoreActivity
import com.stevesoltys.seedvault.ui.toRelativeTime import com.stevesoltys.seedvault.ui.toRelativeTime
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import java.util.concurrent.TimeUnit
private val TAG = SettingsFragment::class.java.name private val TAG = SettingsFragment::class.java.name
@ -39,6 +41,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
private lateinit var apkBackup: TwoStatePreference private lateinit var apkBackup: TwoStatePreference
private lateinit var backupLocation: Preference private lateinit var backupLocation: Preference
private lateinit var backupStatus: Preference private lateinit var backupStatus: Preference
private lateinit var backupScheduling: Preference
private lateinit var backupStorage: TwoStatePreference private lateinit var backupStorage: TwoStatePreference
private lateinit var backupRecoveryCode: Preference private lateinit var backupRecoveryCode: Preference
@ -121,12 +124,14 @@ class SettingsFragment : PreferenceFragmentCompat() {
return@OnPreferenceChangeListener false return@OnPreferenceChangeListener false
} }
backupStatus = findPreference("backup_status")!! backupStatus = findPreference("backup_status")!!
backupScheduling = findPreference("backup_scheduling")!!
backupStorage = findPreference("backup_storage")!! backupStorage = findPreference("backup_storage")!!
backupStorage.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue -> backupStorage.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
val disable = !(newValue as Boolean) val disable = !(newValue as Boolean)
// TODO this should really get moved out off the UI layer
if (disable) { if (disable) {
viewModel.disableStorageBackup() viewModel.cancelFilesBackup()
return@OnPreferenceChangeListener true return@OnPreferenceChangeListener true
} }
onEnablingStorageBackup() onEnablingStorageBackup()
@ -142,6 +147,10 @@ class SettingsFragment : PreferenceFragmentCompat() {
viewModel.lastBackupTime.observe(viewLifecycleOwner) { time -> viewModel.lastBackupTime.observe(viewLifecycleOwner) { time ->
setAppBackupStatusSummary(time) setAppBackupStatusSummary(time)
} }
viewModel.appBackupWorkInfo.observe(viewLifecycleOwner) { workInfo ->
viewModel.onWorkerStateChanged()
setAppBackupSchedulingSummary(workInfo)
}
val backupFiles: Preference = findPreference("backup_files")!! val backupFiles: Preference = findPreference("backup_files")!!
viewModel.filesSummary.observe(viewLifecycleOwner) { summary -> viewModel.filesSummary.observe(viewLifecycleOwner) { summary ->
@ -159,6 +168,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
setBackupEnabledState() setBackupEnabledState()
setBackupLocationSummary() setBackupLocationSummary()
setAutoRestoreState() setAutoRestoreState()
setAppBackupStatusSummary(viewModel.lastBackupTime.value)
setAppBackupSchedulingSummary(viewModel.appBackupWorkInfo.value)
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -204,7 +215,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
private fun trySetBackupEnabled(enabled: Boolean): Boolean { private fun trySetBackupEnabled(enabled: Boolean): Boolean {
return try { return try {
backupManager.isBackupEnabled = enabled backupManager.isBackupEnabled = enabled
if (enabled) viewModel.enableCallLogBackup() viewModel.onBackupEnabled(enabled)
backup.isChecked = enabled backup.isChecked = enabled
true true
} catch (e: RemoteException) { } catch (e: RemoteException) {
@ -244,11 +255,49 @@ class SettingsFragment : PreferenceFragmentCompat() {
backupLocation.summary = storage?.name ?: getString(R.string.settings_backup_location_none) backupLocation.summary = storage?.name ?: getString(R.string.settings_backup_location_none)
} }
private fun setAppBackupStatusSummary(lastBackupInMillis: Long) { private fun setAppBackupStatusSummary(lastBackupInMillis: Long?) {
if (lastBackupInMillis != null) {
// set time of last backup // set time of last backup
val lastBackup = lastBackupInMillis.toRelativeTime(requireContext()) val lastBackup = lastBackupInMillis.toRelativeTime(requireContext())
backupStatus.summary = getString(R.string.settings_backup_status_summary, lastBackup) 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() { private fun onEnablingStorageBackup() {
AlertDialog.Builder(requireContext()) AlertDialog.Builder(requireContext())
@ -268,7 +317,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
LENGTH_LONG LENGTH_LONG
).show() ).show()
} }
viewModel.enableStorageBackup() viewModel.scheduleFilesBackup()
backupStorage.isChecked = true backupStorage.isChecked = true
dialog.dismiss() dialog.dismiss()
} }

View file

@ -1,6 +1,7 @@
package com.stevesoltys.seedvault.settings package com.stevesoltys.seedvault.settings
import android.content.Context import android.content.Context
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.hardware.usb.UsbDevice import android.hardware.usb.UsbDevice
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities 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_TOKEN = "token"
internal const val PREF_KEY_BACKUP_APK = "backup_apk" internal const val PREF_KEY_BACKUP_APK = "backup_apk"
internal const val PREF_KEY_AUTO_RESTORE = "auto_restore" 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_URI = "storageUri"
private const val PREF_KEY_STORAGE_NAME = "storageName" private const val PREF_KEY_STORAGE_NAME = "storageName"
@ -43,6 +47,14 @@ class SettingsManager(private val context: Context) {
@Volatile @Volatile
private var token: Long? = null 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] * This gets accessed by non-UI threads when saving with [PreferenceManager]
* and when [isBackupEnabled] is called during a backup run. * and when [isBackupEnabled] is called during a backup run.
@ -134,13 +146,24 @@ class SettingsManager(private val context: Context) {
fun canDoBackupNow(): Boolean { fun canDoBackupNow(): Boolean {
val storage = getStorage() ?: return false val storage = getStorage() ?: return false
val systemContext = context.getStorageContext { storage.isUsb } val systemContext = context.getStorageContext { storage.isUsb }
return !storage.isUnavailableUsb(systemContext) && !storage.isUnavailableNetwork(context) return !storage.isUnavailableUsb(systemContext) &&
!storage.isUnavailableNetwork(context, useMeteredNetwork)
} }
fun backupApks(): Boolean { fun backupApks(): Boolean {
return prefs.getBoolean(PREF_KEY_BACKUP_APK, true) 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 isBackupEnabled(packageName: String) = !blacklistedApps.contains(packageName)
fun isStorageBackupEnabled() = prefs.getBoolean(PREF_KEY_BACKUP_STORAGE, false) 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, * Returns true if this is storage that requires network access,
* but it isn't available right now. * but it isn't available right now.
*/ */
fun isUnavailableNetwork(context: Context): Boolean { fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean {
return requiresNetwork && !hasUnmeteredInternet(context) 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 cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
val isMetered = cm.isActiveNetworkMetered val isMetered = cm.isActiveNetworkMetered
val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false 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)
} }
} }

View file

@ -11,8 +11,8 @@ import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.net.Uri import android.net.Uri
import android.os.BadParcelableException
import android.os.Process.myUid import android.os.Process.myUid
import android.os.UserHandle
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
@ -22,10 +22,14 @@ import androidx.core.content.ContextCompat.startForegroundService
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.liveData import androidx.lifecycle.liveData
import androidx.lifecycle.map
import androidx.lifecycle.switchMap import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.DiffUtil.calculateDiff 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.R
import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.metadata.MetadataManager 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.StorageBackupJobService
import com.stevesoltys.seedvault.storage.StorageBackupService import com.stevesoltys.seedvault.storage.StorageBackupService
import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP 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.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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -52,16 +59,17 @@ internal class SettingsViewModel(
app: Application, app: Application,
settingsManager: SettingsManager, settingsManager: SettingsManager,
keyManager: KeyManager, keyManager: KeyManager,
private val notificationManager: BackupNotificationManager,
private val metadataManager: MetadataManager, private val metadataManager: MetadataManager,
private val appListRetriever: AppListRetriever, private val appListRetriever: AppListRetriever,
private val storageBackup: StorageBackup, private val storageBackup: StorageBackup,
private val backupManager: IBackupManager, private val backupManager: IBackupManager,
private val backupInitializer: BackupInitializer,
) : RequireProvisioningViewModel(app, settingsManager, keyManager) { ) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
private val contentResolver = app.contentResolver private val contentResolver = app.contentResolver
private val connectivityManager: ConnectivityManager? = private val connectivityManager: ConnectivityManager? =
app.getSystemService(ConnectivityManager::class.java) app.getSystemService(ConnectivityManager::class.java)
private val workManager = WorkManager.getInstance(app)
override val isRestoreOperation = false override val isRestoreOperation = false
@ -69,6 +77,10 @@ internal class SettingsViewModel(
val backupPossible: LiveData<Boolean> = mBackupPossible val backupPossible: LiveData<Boolean> = mBackupPossible
internal val lastBackupTime = metadataManager.lastBackupTime internal val lastBackupTime = metadataManager.lastBackupTime
internal val appBackupWorkInfo =
workManager.getWorkInfosForUniqueWorkLiveData(UNIQUE_WORK_NAME).map {
it.getOrNull(0)
}
private val mAppStatusList = lastBackupTime.switchMap { private val mAppStatusList = lastBackupTime.switchMap {
// updates app list when lastBackupTime changes // updates app list when lastBackupTime changes
@ -82,21 +94,24 @@ internal class SettingsViewModel(
private val _filesSummary = MutableLiveData<String>() private val _filesSummary = MutableLiveData<String>()
internal val filesSummary: LiveData<String> = _filesSummary internal val filesSummary: LiveData<String> = _filesSummary
private val _initEvent = MutableLiveEvent<Boolean>()
val initEvent: LiveEvent<Boolean> = _initEvent
private val storageObserver = object : ContentObserver(null) { private val storageObserver = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uris: MutableCollection<Uri>, flags: Int) { override fun onChange(selfChange: Boolean, uris: MutableCollection<Uri>, flags: Int) {
onStorageLocationChanged() onStoragePropertiesChanged()
} }
} }
private inner class NetworkObserver : ConnectivityManager.NetworkCallback() { private inner class NetworkObserver : ConnectivityManager.NetworkCallback() {
var registered = false var registered = false
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
onStorageLocationChanged() onStoragePropertiesChanged()
} }
override fun onLost(network: Network) { override fun onLost(network: Network) {
super.onLost(network) super.onLost(network)
onStorageLocationChanged() onStoragePropertiesChanged()
} }
} }
@ -111,13 +126,39 @@ internal class SettingsViewModel(
// ensures the lastBackupTime LiveData gets set // ensures the lastBackupTime LiveData gets set
metadataManager.getLastBackupTime() metadataManager.getLastBackupTime()
} }
onStorageLocationChanged() onStoragePropertiesChanged()
loadFilesSummary() loadFilesSummary()
} }
override fun onStorageLocationChanged() { override fun onStorageLocationChanged() {
val storage = settingsManager.getStorage() ?: return 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 // register storage observer
try { try {
contentResolver.unregisterContentObserver(storageObserver) contentResolver.unregisterContentObserver(storageObserver)
@ -139,19 +180,8 @@ internal class SettingsViewModel(
connectivityManager?.registerNetworkCallback(request, networkCallback) connectivityManager?.registerNetworkCallback(request, networkCallback)
networkCallback.registered = true networkCallback.registered = true
} }
// update whether we can do backups right now or not
if (settingsManager.isStorageBackupEnabled()) { onWorkerStateChanged()
// 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)
}
} }
override fun onCleared() { override fun onCleared() {
@ -163,25 +193,27 @@ internal class SettingsViewModel(
} }
internal fun backupNow() { internal fun backupNow() {
// maybe replace the check below with one that checks if our transport service is running viewModelScope.launch(Dispatchers.IO) {
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) {
if (settingsManager.isStorageBackupEnabled()) { if (settingsManager.isStorageBackupEnabled()) {
val i = Intent(app, StorageBackupService::class.java) val i = Intent(app, StorageBackupService::class.java)
// this starts an app backup afterwards // this starts an app backup afterwards
i.putExtra(EXTRA_START_APP_BACKUP, true) i.putExtra(EXTRA_START_APP_BACKUP, true)
startForegroundService(app, i) startForegroundService(app, i)
} else { } else {
requestBackup(app) val isUsb = settingsManager.getStorage()?.isUsb ?: false
AppBackupWorker.scheduleNow(app, reschedule = !isUsb)
} }
} }
} }
private fun getAppStatusResult(): LiveData<AppStatusResult> = liveData(Dispatchers.Default) { private fun getAppStatusResult(): LiveData<AppStatusResult> = 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 oldList = mAppStatusList.value?.appStatusList ?: emptyList()
val diff = calculateDiff(AppStatusDiff(oldList, list)) val diff = calculateDiff(AppStatusDiff(oldList, list))
emit(AppStatusResult(list, diff)) 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. * Ensures that the call log will be included in backups.
* *
@ -223,9 +279,17 @@ internal class SettingsViewModel(
return keyManager.hasMainKey() return keyManager.hasMainKey()
} }
fun enableStorageBackup() { fun scheduleAppBackup(existingWorkPolicy: ExistingPeriodicWorkPolicy) {
val storage = settingsManager.getStorage() ?: error("no storage available") val storage = settingsManager.getStorage() ?: error("no storage available")
if (!storage.isUsb) BackupJobService.scheduleJob( if (!storage.isUsb && backupManager.isBackupEnabled) {
AppBackupWorker.schedule(app, settingsManager, existingWorkPolicy)
}
}
fun scheduleFilesBackup() {
val storage = settingsManager.getStorage() ?: error("no storage available")
if (!storage.isUsb && settingsManager.isStorageBackupEnabled()) {
BackupJobService.scheduleJob(
context = app, context = app,
jobServiceClass = StorageBackupJobService::class.java, jobServiceClass = StorageBackupJobService::class.java,
periodMillis = HOURS.toMillis(24), periodMillis = HOURS.toMillis(24),
@ -235,8 +299,13 @@ internal class SettingsViewModel(
charging = true charging = true
) )
} }
}
fun disableStorageBackup() { fun cancelAppBackup() {
AppBackupWorker.unschedule(app)
}
fun cancelFilesBackup() {
BackupJobService.cancelJob(app) BackupJobService.cancelJob(app)
} }
@ -264,13 +333,4 @@ internal class SettingsViewModel(
Toast.makeText(app, str, LENGTH_LONG).show() 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)
}
}
} }

View file

@ -1,7 +1,8 @@
package com.stevesoltys.seedvault.storage package com.stevesoltys.seedvault.storage
import android.content.Intent 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.BackupObserver
import org.calyxos.backup.storage.api.RestoreObserver import org.calyxos.backup.storage.api.RestoreObserver
import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.api.StorageBackup
@ -23,6 +24,7 @@ force running with:
adb shell cmd jobscheduler run -f com.stevesoltys.seedvault 0 adb shell cmd jobscheduler run -f com.stevesoltys.seedvault 0
*/ */
internal class StorageBackupJobService : BackupJobService(StorageBackupService::class.java) internal class StorageBackupJobService : BackupJobService(StorageBackupService::class.java)
internal class StorageBackupService : BackupService() { internal class StorageBackupService : BackupService() {
@ -32,6 +34,7 @@ internal class StorageBackupService : BackupService() {
} }
override val storageBackup: StorageBackup by inject() override val storageBackup: StorageBackup by inject()
private val settingsManager: SettingsManager by inject()
// use lazy delegate because context isn't available during construction time // use lazy delegate because context isn't available during construction time
override val backupObserver: BackupObserver by lazy { override val backupObserver: BackupObserver by lazy {
@ -40,7 +43,8 @@ internal class StorageBackupService : BackupService() {
override fun onBackupFinished(intent: Intent, success: Boolean) { override fun onBackupFinished(intent: Intent, success: Boolean) {
if (intent.getBooleanExtra(EXTRA_START_APP_BACKUP, false)) { if (intent.getBooleanExtra(EXTRA_START_APP_BACKUP, false)) {
requestBackup(applicationContext) val isUsb = settingsManager.getStorage()?.isUsb ?: false
AppBackupWorker.scheduleNow(applicationContext, reschedule = !isUsb)
} }
} }
} }

View file

@ -130,8 +130,8 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup) return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup)
} }
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long = runBlocking { override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
backupCoordinator.getBackupQuota(packageName, isFullBackup) return backupCoordinator.getBackupQuota(packageName, isFullBackup)
} }
override fun clearBackupData(packageInfo: PackageInfo): Int = runBlocking { override fun clearBackupData(packageInfo: PackageInfo): Int = runBlocking {

View file

@ -2,18 +2,13 @@ package com.stevesoltys.seedvault.transport
import android.app.Service import android.app.Service
import android.app.backup.IBackupManager import android.app.backup.IBackupManager
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.crypto.KeyManager 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 com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.koin.core.context.GlobalContext.get
private val TAG = ConfigurableBackupTransportService::class.java.simpleName 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
}
}

View file

@ -13,21 +13,17 @@ import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
import android.app.backup.RestoreSet import android.app.backup.RestoreSet
import android.content.Context import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.Clock import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.metadata.BackupType import com.stevesoltys.seedvault.metadata.BackupType
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageState 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.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR 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.StoragePlugin
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
@ -65,7 +61,6 @@ internal class BackupCoordinator(
private val plugin: StoragePlugin, private val plugin: StoragePlugin,
private val kv: KVBackup, private val kv: KVBackup,
private val full: FullBackup, private val full: FullBackup,
private val apkBackup: ApkBackup,
private val clock: Clock, private val clock: Clock,
private val packageService: PackageService, private val packageService: PackageService,
private val metadataManager: MetadataManager, private val metadataManager: MetadataManager,
@ -91,12 +86,13 @@ internal class BackupCoordinator(
* @return the token of the new [RestoreSet]. * @return the token of the new [RestoreSet].
*/ */
@Throws(IOException::class) @Throws(IOException::class)
private suspend fun startNewRestoreSet(): Long { private suspend fun startNewRestoreSet() {
val token = clock.time() val token = clock.time()
Log.i(TAG, "Starting new RestoreSet with token $token...") Log.i(TAG, "Starting new RestoreSet with token $token...")
settingsManager.setNewToken(token) settingsManager.setNewToken(token)
plugin.startNewRestoreSet(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 { suspend fun initializeDevice(): Int = try {
// we don't respect the intended system behavior here by always starting a new [RestoreSet] // we don't respect the intended system behavior here by always starting a new [RestoreSet]
// instead of simply deleting the current one // instead of simply deleting the current one
val token = startNewRestoreSet() startNewRestoreSet()
Log.i(TAG, "Initialize Device!") Log.i(TAG, "Initialize Device!")
plugin.initializeDevice() 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 // [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully // so we remember that we initialized successfully
state.calledInitialize = true state.calledInitialize = true
TRANSPORT_OK TRANSPORT_OK
} catch (e: IOException) { } catch (e: Exception) {
Log.e(TAG, "Error initializing device", e) Log.e(TAG, "Error initializing device", e)
// Show error notification if we needed init or were ready for backups // Show error notification if we needed init or were ready for backups
if (metadataManager.requiresInit || settingsManager.canDoBackupNow()) nm.onBackupError() if (metadataManager.requiresInit || settingsManager.canDoBackupNow()) nm.onBackupError()
@ -156,13 +148,7 @@ internal class BackupCoordinator(
* otherwise for key-value backup. * otherwise for key-value backup.
* @return Current limit on backup size in bytes. * @return Current limit on backup size in bytes.
*/ */
suspend fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { 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))
}
// report back quota // report back quota
Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.") Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.")
val quota = if (isFullBackup) full.getQuota() else kv.getQuota() val quota = if (isFullBackup) full.getQuota() else kv.getQuota()
@ -233,14 +219,11 @@ internal class BackupCoordinator(
state.cancelReason = UNKNOWN_ERROR state.cancelReason = UNKNOWN_ERROR
if (metadataManager.requiresInit) { if (metadataManager.requiresInit) {
Log.w(TAG, "Metadata requires re-init!") Log.w(TAG, "Metadata requires re-init!")
// start a new restore set to upgrade from legacy format // Tell the system that we are not initialized, it will initialize us afterwards.
// by starting a clean backup with all files using the new version // This will start a new restore set to upgrade from legacy format
try { // by starting a clean backup with all files using the new version.
startNewRestoreSet() //
} catch (e: IOException) { // This causes a backup error, but things should go back to normal afterwards.
Log.e(TAG, "Error starting new restore set", e)
}
// this causes a backup error, but things should go back to normal afterwards
return TRANSPORT_NOT_INITIALIZED return TRANSPORT_NOT_INITIALIZED
} }
val token = settingsManager.getToken() ?: error("no token in performFullBackup") val token = settingsManager.getToken() ?: error("no token in performFullBackup")
@ -369,9 +352,9 @@ internal class BackupCoordinator(
// tell K/V backup to finish // tell K/V backup to finish
var result = kv.finishBackup() var result = kv.finishBackup()
if (result == TRANSPORT_OK) { 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 // call onPackageBackedUp for @pm@ only if we can do backups right now
if (!isPmBackup || settingsManager.canDoBackupNow()) { if (isNormalBackup || settingsManager.canDoBackupNow()) {
try { try {
onPackageBackedUp(packageInfo, BackupType.KV, size) onPackageBackedUp(packageInfo, BackupType.KV, size)
} catch (e: Exception) { } catch (e: Exception) {
@ -379,17 +362,6 @@ internal class BackupCoordinator(
result = TRANSPORT_PACKAGE_REJECTED 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 result
} }
@ -418,65 +390,6 @@ internal class BackupCoordinator(
else -> throw IllegalStateException("Unexpected state in finishBackup()") 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?) { private suspend fun onPackageBackedUp(packageInfo: PackageInfo, type: BackupType, size: Long?) {
plugin.getMetadataOutputStream().use { plugin.getMetadataOutputStream().use {
metadataManager.onPackageBackedUp(packageInfo, type, size, it) metadataManager.onPackageBackedUp(packageInfo, type, size, it)
@ -503,7 +416,10 @@ internal class BackupCoordinator(
// back off if storage is removable and not available right now // back off if storage is removable and not available right now
storage.isUnavailableUsb(context) -> longBackoff storage.isUnavailableUsb(context) -> longBackoff
// back off if storage is on network, but we have no access // 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 // otherwise no back off
else -> 0L else -> 0L
} }

View file

@ -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()
}
}
}
}

View file

@ -4,6 +4,7 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
val backupModule = module { val backupModule = module {
single { BackupInitializer(get()) }
single { InputFactory() } single { InputFactory() }
single { single {
PackageService( PackageService(
@ -13,14 +14,6 @@ val backupModule = module {
plugin = get() plugin = get()
) )
} }
single {
ApkBackup(
pm = androidContext().packageManager,
crypto = get(),
settingsManager = get(),
metadataManager = get()
)
}
single<KvDbManager> { KvDbManagerImpl(androidContext()) } single<KvDbManager> { KvDbManagerImpl(androidContext()) }
single { single {
KVBackup( KVBackup(
@ -45,7 +38,6 @@ val backupModule = module {
plugin = get(), plugin = get(),
kv = get(), kv = get(),
full = get(), full = get(),
apkBackup = get(),
clock = get(), clock = get(),
packageService = get(), packageService = get(),
metadataManager = get(), metadataManager = get(),

View file

@ -73,6 +73,22 @@ internal class PackageService(
return packageArray 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<PackageInfo>
@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, * A list of packages that will not be backed up,
* because they are currently force-stopped for example. * because they are currently force-stopped for example.
@ -90,9 +106,9 @@ internal class PackageService(
}.sortedBy { packageInfo -> }.sortedBy { packageInfo ->
packageInfo.packageName packageInfo.packageName
}.also { notAllowed -> }.also { notAllowed ->
// log eligible packages // log packages that don't get backed up
if (Log.isLoggable(TAG, INFO)) { 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 }) 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 { fun getVersionName(packageName: String): String? = try {
packageManager.getPackageInfo(packageName, 0).versionName packageManager.getPackageInfo(packageName, 0).versionName
} catch (e: PackageManager.NameNotFoundException) { } 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 { internal fun PackageInfo.isUserVisible(context: Context): Boolean {
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false
return !isNotUpdatedSystemApp() && instrumentation == null && packageName != context.packageName return !isNotUpdatedSystemApp() && instrumentation == null && packageName != context.packageName

View file

@ -17,11 +17,13 @@ abstract class BackupActivity : AppCompatActivity() {
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
protected fun showFragment(f: Fragment, addToBackStack: Boolean = false) { protected fun showFragment(f: Fragment, addToBackStack: Boolean = false, tag: String? = null) {
val fragmentTransaction = supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction().apply {
.replace(R.id.fragment, f) if (tag == null) replace(R.id.fragment, f)
if (addToBackStack) fragmentTransaction.addToBackStack(null) else replace(R.id.fragment, f, tag)
fragmentTransaction.commit() if (addToBackStack) addToBackStack(null)
commit()
}
} }
} }

View file

@ -1,6 +1,7 @@
package com.stevesoltys.seedvault.ui.notification package com.stevesoltys.seedvault.ui.notification
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.NotificationManager.IMPORTANCE_DEFAULT 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.restore.REQUEST_CODE_UNINSTALL
import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST
import com.stevesoltys.seedvault.settings.SettingsActivity import com.stevesoltys.seedvault.settings.SettingsActivity
import com.stevesoltys.seedvault.transport.backup.ExpectedAppTotals
import kotlin.math.min import kotlin.math.min
private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver" private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver"
private const val CHANNEL_ID_SUCCESS = "NotificationBackupSuccess" private const val CHANNEL_ID_SUCCESS = "NotificationBackupSuccess"
private const val CHANNEL_ID_ERROR = "NotificationError" private const val CHANNEL_ID_ERROR = "NotificationError"
private const val CHANNEL_ID_RESTORE_ERROR = "NotificationRestoreError" 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_SUCCESS = 2
private const val NOTIFICATION_ID_ERROR = 3 private const val NOTIFICATION_ID_ERROR = 3
private const val NOTIFICATION_ID_RESTORE_ERROR = 4 private const val NOTIFICATION_ID_RESTORE_ERROR = 4
@ -50,14 +50,6 @@ internal class BackupNotificationManager(private val context: Context) {
createNotificationChannel(getErrorChannel()) createNotificationChannel(getErrorChannel())
createNotificationChannel(getRestoreErrorChannel()) 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 { private fun getObserverChannel(): NotificationChannel {
val title = context.getString(R.string.notification_channel_title) 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( fun onApkBackup(packageName: String, name: CharSequence, transferred: Int, expected: Int) {
expectedPackages: Int, Log.i(TAG, "$transferred/$expected - $name ($packageName)")
appTotals: ExpectedAppTotals, val text = context.getString(R.string.notification_apk_text, name)
) { updateBackupNotification(text, transferred, expected)
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}")
} }
/** /**
* This should get called before [onBackupUpdate]. * This should get called for recording apps we don't back up.
* In case of d2d backups, this actually gets called some time after
* some apps were already backed up, so [onBackupUpdate] was called several times.
*/ */
fun onOptOutAppBackup(packageName: String, transferred: Int, expected: Int) { fun onAppsNotBackedUp() {
if (optOutAppsDone) return 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) { * Call after [onApkBackup] or [onAppsNotBackedUp] were called.
updateBackgroundBackupNotification(text) */
} else { fun onApkBackupDone() {
updateBackupNotification(text, transferred, expected + (expectedApps ?: 0)) nm.cancel(NOTIFICATION_ID_OBSERVER)
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 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, * 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) { fun onBackupUpdate(app: CharSequence, transferred: Int, total: Int) {
val expected = expectedApps ?: error("expectedApps is null") updateBackupNotification(app, min(transferred, total), total)
val addend = expectedOptOutApps ?: 0
updateBackupNotification(
infoText = app,
transferred = min(transferred + addend, expected + addend),
expected = expected + addend
)
} }
private fun updateBackupNotification( private fun updateBackupNotification(
infoText: CharSequence, text: CharSequence,
transferred: Int, transferred: Int = 0,
expected: Int, expected: Int = 0,
) { ) {
@Suppress("MagicNumber") val notification = getBackupNotification(text, transferred, expected)
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()
nm.notify(NOTIFICATION_ID_OBSERVER, notification) nm.notify(NOTIFICATION_ID_OBSERVER, notification)
} }
private fun updateBackgroundBackupNotification(infoText: CharSequence) { fun getBackupNotification(text: CharSequence, progress: Int = 0, total: Int = 0): Notification {
Log.i(TAG, "$infoText") return Builder(context, CHANNEL_ID_OBSERVER).apply {
val notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
setSmallIcon(R.drawable.ic_cloud_upload) setSmallIcon(R.drawable.ic_cloud_upload)
setContentTitle(context.getString(R.string.notification_title)) setContentTitle(context.getString(R.string.notification_title))
setContentText(text)
setOngoing(true) setOngoing(true)
setShowWhen(false) setShowWhen(false)
setWhen(System.currentTimeMillis()) setProgress(total, progress, progress == 0 && total == 0)
setProgress(0, 0, true) priority = PRIORITY_DEFAULT
priority = PRIORITY_LOW foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
}.build() }.build()
nm.notify(NOTIFICATION_ID_BACKGROUND, notification)
} }
fun onServiceDestroyed() { 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 = val titleRes =
if (success) R.string.notification_success_title else R.string.notification_failed_title if (success) R.string.notification_success_title else R.string.notification_failed_title
val total = expectedAppTotals?.appsTotal val contentText = if (numBackedUp == null) null else {
val contentText = if (numBackedUp == null || total == null) null else {
val sizeStr = Formatter.formatShortFileSize(context, size) val sizeStr = Formatter.formatShortFileSize(context, size)
context.getString(R.string.notification_success_text, numBackedUp, total, sizeStr) context.getString(R.string.notification_success_text, numBackedUp, total, sizeStr)
} }
@ -224,20 +190,6 @@ internal class BackupNotificationManager(private val context: Context) {
}.build() }.build()
nm.cancel(NOTIFICATION_ID_OBSERVER) nm.cancel(NOTIFICATION_ID_OBSERVER)
nm.notify(NOTIFICATION_ID_SUCCESS, notification) 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") @SuppressLint("RestrictedApi")

View file

@ -3,6 +3,7 @@ package com.stevesoltys.seedvault.ui.notification
import android.app.backup.BackupProgress import android.app.backup.BackupProgress
import android.app.backup.IBackupObserver import android.app.backup.IBackupObserver
import android.content.Context import android.content.Context
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.PackageManager.NameNotFoundException
import android.util.Log import android.util.Log
import android.util.Log.INFO 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.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.transport.backup.BackupRequester import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.transport.backup.ExpectedAppTotals import com.stevesoltys.seedvault.worker.BackupRequester
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@ -21,18 +22,20 @@ internal class NotificationBackupObserver(
private val context: Context, private val context: Context,
private val backupRequester: BackupRequester, private val backupRequester: BackupRequester,
private val requestedPackages: Int, private val requestedPackages: Int,
appTotals: ExpectedAppTotals,
) : IBackupObserver.Stub(), KoinComponent { ) : IBackupObserver.Stub(), KoinComponent {
private val nm: BackupNotificationManager by inject() private val nm: BackupNotificationManager by inject()
private val metadataManager: MetadataManager by inject() private val metadataManager: MetadataManager by inject()
private val packageService: PackageService by inject()
private var currentPackage: String? = null private var currentPackage: String? = null
private var numPackages: Int = 0 private var numPackages: Int = 0
private var numPackagesToReport: Int = 0
private var pmCounted: Boolean = false
init { init {
// Inform the notification manager that a backup has started // Inform the notification manager that a backup has started
// and inform about the expected numbers, so it can compute a total. // and inform about the expected numbers of apps.
nm.onBackupStarted(requestedPackages, appTotals) nm.onBackupStarted(requestedPackages)
} }
/** /**
@ -63,6 +66,26 @@ internal class NotificationBackupObserver(
if (isLoggable(TAG, INFO)) { if (isLoggable(TAG, INFO)) {
Log.i(TAG, "Completed. Target: $target, status: $status") 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 // often [onResult] gets called right away without any [onUpdate] call
showProgressNotification(target) showProgressNotification(target)
} }
@ -80,9 +103,14 @@ internal class NotificationBackupObserver(
Log.i(TAG, "Backup finished $numPackages/$requestedPackages. Status: $status") Log.i(TAG, "Backup finished $numPackages/$requestedPackages. Status: $status")
} }
val success = status == 0 val success = status == 0
val numBackedUp = if (success) metadataManager.getPackagesNumBackedUp() else null
val size = if (success) metadataManager.getPackagesBackupSize() else 0L 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 currentPackage = packageName
val appName = getAppName(packageName) val appName = getAppName(packageName)
val app = if (appName != packageName) { val name = if (appName != packageName) {
"${getAppName(packageName)} ($packageName)" appName
} else { } else {
packageName context.getString(R.string.backup_section_system)
} }
numPackages += 1 Log.i(TAG, "$numPackages/$requestedPackages - $appName ($packageName)")
nm.onBackupUpdate(app, numPackages) nm.onBackupUpdate(name, numPackages, requestedPackages)
} }
private fun getAppName(packageId: String): CharSequence = getAppName(context, packageId) private fun getAppName(packageId: String): CharSequence = getAppName(context, packageId)

View file

@ -1,7 +1,6 @@
package com.stevesoltys.seedvault.ui.recoverycode package com.stevesoltys.seedvault.ui.recoverycode
import android.app.backup.IBackupManager import android.app.backup.IBackupManager
import android.os.UserHandle
import android.util.Log import android.util.Log
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import cash.z.ecc.android.bip39.Mnemonics 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.App
import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.transport.TRANSPORT_ID import com.stevesoltys.seedvault.transport.backup.BackupInitializer
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import com.stevesoltys.seedvault.ui.LiveEvent import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
@ -32,7 +30,7 @@ internal class RecoveryCodeViewModel(
private val crypto: Crypto, private val crypto: Crypto,
private val keyManager: KeyManager, private val keyManager: KeyManager,
private val backupManager: IBackupManager, private val backupManager: IBackupManager,
private val backupCoordinator: BackupCoordinator, private val backupInitializer: BackupInitializer,
private val notificationManager: BackupNotificationManager, private val notificationManager: BackupNotificationManager,
private val storageBackup: StorageBackup, private val storageBackup: StorageBackup,
) : AndroidViewModel(app) { ) : AndroidViewModel(app) {
@ -102,17 +100,16 @@ internal class RecoveryCodeViewModel(
*/ */
fun reinitializeBackupLocation() { fun reinitializeBackupLocation() {
Log.d(TAG, "Re-initializing backup location...") Log.d(TAG, "Re-initializing backup location...")
// TODO this code is almost identical to BackupStorageViewModel#onLocationSet(), unify?
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
// remove old storage snapshots and clear cache // remove old storage snapshots and clear cache
storageBackup.deleteAllSnapshots() storageBackup.deleteAllSnapshots()
storageBackup.clearCache() storageBackup.clearCache()
try { try {
// initialize the new location // initialize the new location
if (backupManager.isBackupEnabled) backupManager.initializeTransportsForUser( if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) {
UserHandle.myUserId(), // no-op
arrayOf(TRANSPORT_ID), }
null
)
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error starting new RestoreSet", e) Log.e(TAG, "Error starting new RestoreSet", e)
} }

View file

@ -1,30 +1,30 @@
package com.stevesoltys.seedvault.ui.storage package com.stevesoltys.seedvault.ui.storage
import android.app.Application import android.app.Application
import android.app.backup.BackupProgress
import android.app.backup.IBackupManager import android.app.backup.IBackupManager
import android.app.backup.IBackupObserver import android.app.job.JobInfo
import android.net.Uri import android.net.Uri
import android.os.UserHandle
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.TRANSPORT_ID import com.stevesoltys.seedvault.storage.StorageBackupJobService
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import com.stevesoltys.seedvault.transport.backup.BackupInitializer
import com.stevesoltys.seedvault.transport.requestBackup import com.stevesoltys.seedvault.worker.AppBackupWorker
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.api.StorageBackup
import org.calyxos.backup.storage.backup.BackupJobService
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit
private val TAG = BackupStorageViewModel::class.java.simpleName private val TAG = BackupStorageViewModel::class.java.simpleName
internal class BackupStorageViewModel( internal class BackupStorageViewModel(
private val app: Application, private val app: Application,
private val backupManager: IBackupManager, private val backupManager: IBackupManager,
private val backupCoordinator: BackupCoordinator, private val backupInitializer: BackupInitializer,
private val storageBackup: StorageBackup, private val storageBackup: StorageBackup,
settingsManager: SettingsManager, settingsManager: SettingsManager,
) : StorageViewModel(app, settingsManager) { ) : StorageViewModel(app, settingsManager) {
@ -33,19 +33,39 @@ internal class BackupStorageViewModel(
override fun onLocationSet(uri: Uri) { override fun onLocationSet(uri: Uri) {
val isUsb = saveStorage(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) { viewModelScope.launch(Dispatchers.IO) {
// remove old storage snapshots and clear cache // 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.deleteAllSnapshots()
storageBackup.clearCache() storageBackup.clearCache()
try { try {
// initialize the new location (if backups are enabled) // initialize the new location (if backups are enabled)
if (backupManager.isBackupEnabled) backupManager.initializeTransportsForUser( if (backupManager.isBackupEnabled) {
UserHandle.myUserId(), val onError = {
arrayOf(TRANSPORT_ID), Log.e(TAG, "Error starting new RestoreSet")
// if storage is on USB and this is not SetupWizard, do a backup right away onInitializationError()
InitializationObserver(isUsb && !isSetupWizard) }
) else { backupInitializer.initialize(onError) {
InitializationObserver(false).backupFinished(0) 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) { } catch (e: IOException) {
Log.e(TAG, "Error starting new RestoreSet", e) Log.e(TAG, "Error starting new RestoreSet", e)
@ -54,32 +74,27 @@ internal class BackupStorageViewModel(
} }
} }
@WorkerThread private fun scheduleBackupWorkers() {
private inner class InitializationObserver(val requestBackup: Boolean) : val storage = settingsManager.getStorage() ?: error("no storage available")
IBackupObserver.Stub() { if (!storage.isUsb) {
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) { if (backupManager.isBackupEnabled) {
// noop 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
)
}
} }
override fun onResult(target: String, status: Int) { private fun cancelBackupWorkers() {
// noop AppBackupWorker.unschedule(app)
} BackupJobService.cancelJob(app)
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 onInitializationError() { private fun onInitializationError() {

View file

@ -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.annotation.SuppressLint
import android.content.pm.PackageInfo 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.ApkSplit
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState
import com.stevesoltys.seedvault.settings.SettingsManager 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.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.IOException import java.io.IOException
@ -44,7 +50,6 @@ internal class ApkBackup(
@SuppressLint("NewApi") // can be removed when minSdk is set to 30 @SuppressLint("NewApi") // can be removed when minSdk is set to 30
suspend fun backupApkIfNecessary( suspend fun backupApkIfNecessary(
packageInfo: PackageInfo, packageInfo: PackageInfo,
packageState: PackageState,
streamGetter: suspend (name: String) -> OutputStream, streamGetter: suspend (name: String) -> OutputStream,
): PackageMetadata? { ): PackageMetadata? {
// do not back up @pm@ // do not back up @pm@
@ -118,11 +123,10 @@ internal class ApkBackup(
val splits = val splits =
if (packageInfo.splitNames == null) null else backupSplitApks(packageInfo, streamGetter) 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 updated metadata
return PackageMetadata( return packageMetadata.copy(
state = packageState,
version = version, version = version,
installer = pm.getInstallSourceInfo(packageName).installingPackageName, installer = pm.getInstallSourceInfo(packageName).installingPackageName,
splits = splits, splits = splits,

View file

@ -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)
}
}

View file

@ -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<AppBackupWorker>(
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<AppBackupWorker>()
.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,
)
}

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0 * 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.BackupManager
import android.app.backup.IBackupManager import android.app.backup.IBackupManager
@ -12,6 +12,7 @@ import android.os.RemoteException
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.BackupMonitor 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.BackupNotificationManager
import com.stevesoltys.seedvault.ui.notification.NotificationBackupObserver import com.stevesoltys.seedvault.ui.notification.NotificationBackupObserver
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -34,12 +35,13 @@ internal class BackupRequester(
val packageService: PackageService, val packageService: PackageService,
) : KoinComponent { ) : KoinComponent {
val isBackupEnabled: Boolean get() = backupManager.isBackupEnabled
private val packages = packageService.eligiblePackages private val packages = packageService.eligiblePackages
private val observer = NotificationBackupObserver( private val observer = NotificationBackupObserver(
context = context, context = context,
backupRequester = this, backupRequester = this,
requestedPackages = packages.size, requestedPackages = packages.size,
appTotals = packageService.expectedAppTotals,
) )
private val monitor = BackupMonitor() private val monitor = BackupMonitor()
@ -100,7 +102,7 @@ internal class BackupRequester(
(packageIndex + NUM_PACKAGES_PER_TRANSACTION).coerceAtMost(packages.size) (packageIndex + NUM_PACKAGES_PER_TRANSACTION).coerceAtMost(packages.size)
val packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray() val packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray()
val numBackingUp = packageIndex + packageChunk.size 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 packageIndex += packageChunk.size
return packageChunk return packageChunk
} }

View file

@ -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()
)
}
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/textColorSecondary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M15,13H16.5V15.82L18.94,17.23L18.19,18.53L15,16.69V13M19,8H5V19H9.67C9.24,18.09 9,17.07 9,16A7,7 0 0,1 16,9C17.07,9 18.09,9.24 19,9.67V8M5,21C3.89,21 3,20.1 3,19V5C3,3.89 3.89,3 5,3H6V1H8V3H16V1H18V3H19A2,2 0 0,1 21,5V11.1C22.24,12.36 23,14.09 23,16A7,7 0 0,1 16,23C14.09,23 12.36,22.24 11.1,21H5M16,11.15A4.85,4.85 0 0,0 11.15,16C11.15,18.68 13.32,20.85 16,20.85A4.85,4.85 0 0,0 20.85,16C20.85,13.32 18.68,11.15 16,11.15Z" />
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/textColorSecondary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M15.67,4H14V2h-4v2H8.33C7.6,4 7,4.6 7,5.33v15.33C7,21.4 7.6,22 8.33,22h7.33c0.74,0 1.34,-0.6 1.34,-1.33V5.33C17,4.6 16.4,4 15.67,4zM11,20v-5.5H9L13,7v5.5h2L11,20z" />
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/textColorSecondary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M19 17H21V11H19M19 21H21V19H19M1 21H17V9H21V1" />
</vector>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2024 The Calyx Institute
SPDX-License-Identifier: Apache-2.0
-->
<resources>
<string-array name="settings_scheduling_frequency_labels">
<item>@string/settings_scheduling_frequency_12_hours</item>
<item>@string/settings_scheduling_frequency_daily</item>
<item>@string/settings_scheduling_frequency_3_days</item>
<item>@string/settings_scheduling_frequency_weekly</item>
</string-array>
<string-array name="settings_scheduling_frequency_values">
<item>43200000</item>
<item>86400000</item>
<item>259200000</item>
<item>604800000</item>
</string-array>
</resources>

View file

@ -30,6 +30,11 @@
<string name="settings_backup_apk_dialog_disable">Disable app backup</string> <string name="settings_backup_apk_dialog_disable">Disable app backup</string>
<string name="settings_backup_status_title">Backup status</string> <string name="settings_backup_status_title">Backup status</string>
<string name="settings_backup_status_summary">Last backup: %1$s</string> <string name="settings_backup_status_summary">Last backup: %1$s</string>
<string name="settings_backup_status_next_backup">Next backup: %1$s</string>
<string name="settings_backup_status_next_backup_estimate">Next backup (estimate): %1$s</string>
<string name="settings_backup_status_next_backup_past">once conditions are fulfilled</string>
<string name="settings_backup_status_next_backup_usb">Backups will happen automatically when you plug in your USB drive</string>
<string name="settings_backup_scheduling_title">Backup scheduling</string>
<string name="settings_backup_exclude_apps">Exclude apps</string> <string name="settings_backup_exclude_apps">Exclude apps</string>
<string name="settings_backup_now">Backup now</string> <string name="settings_backup_now">Backup now</string>
<string name="settings_category_storage">Storage backup (beta)</string> <string name="settings_category_storage">Storage backup (beta)</string>
@ -46,6 +51,15 @@
<string name="settings_backup_new_code_dialog_message">To continue using app backups, you need to generate a new recovery code.\n\nWe are sorry for the inconvenience.</string> <string name="settings_backup_new_code_dialog_message">To continue using app backups, you need to generate a new recovery code.\n\nWe are sorry for the inconvenience.</string>
<string name="settings_backup_new_code_code_dialog_ok">New code</string> <string name="settings_backup_new_code_code_dialog_ok">New code</string>
<string name="settings_scheduling_frequency_title">Backup frequency</string>
<string name="settings_scheduling_frequency_12_hours">Every 12 hours</string>
<string name="settings_scheduling_frequency_daily">Daily</string>
<string name="settings_scheduling_frequency_3_days">Every 3 days</string>
<string name="settings_scheduling_frequency_weekly">Weekly</string>
<string name="settings_scheduling_category_conditions_title">Conditions</string>
<string name="settings_scheduling_metered_title">Back up when using mobile data</string>
<string name="settings_scheduling_charging_title">Back up only when charging</string>
<string name="settings_expert_title">Expert settings</string> <string name="settings_expert_title">Expert settings</string>
<string name="settings_expert_quota_title">Unlimited app quota</string> <string name="settings_expert_quota_title">Unlimited app quota</string>
<string name="settings_expert_quota_summary">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.</string> <string name="settings_expert_quota_summary">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.</string>
@ -122,6 +136,8 @@
<string name="notification_channel_title">Backup notification</string> <string name="notification_channel_title">Backup notification</string>
<string name="notification_success_channel_title">Success notification</string> <string name="notification_success_channel_title">Success notification</string>
<string name="notification_title">Backup running</string> <string name="notification_title">Backup running</string>
<string name="notification_apk_text">Backing up APK of %s</string>
<string name="notification_apk_not_backed_up">Saving list of apps we can not back up.</string>
<string name="notification_backup_already_running">Backup already in progress</string> <string name="notification_backup_already_running">Backup already in progress</string>
<string name="notification_backup_disabled">Backup not enabled</string> <string name="notification_backup_disabled">Backup not enabled</string>

View file

@ -46,6 +46,13 @@
app:summary="@string/settings_backup_apk_summary" app:summary="@string/settings_backup_apk_summary"
app:title="@string/settings_backup_apk_title" /> app:title="@string/settings_backup_apk_title" />
<androidx.preference.Preference
app:fragment="com.stevesoltys.seedvault.settings.SchedulingFragment"
app:icon="@drawable/ic_access_time"
app:key="backup_scheduling"
app:title="@string/settings_backup_scheduling_title"
app:summary="Next backup: Never" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:title="@string/settings_category_storage"> <PreferenceCategory android:title="@string/settings_category_storage">

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ListPreference
android:defaultValue="86400000"
android:entries="@array/settings_scheduling_frequency_labels"
android:entryValues="@array/settings_scheduling_frequency_values"
app:icon="@drawable/ic_access_time"
app:key="scheduling_frequency"
app:title="@string/settings_scheduling_frequency_title"
app:useSimpleSummaryProvider="true" />
<PreferenceCategory
app:key="scheduling_category_conditions"
app:singleLineTitle="false"
app:title="@string/settings_scheduling_category_conditions_title">
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="scheduling_metered"
android:title="@string/settings_scheduling_metered_title"
app:icon="@drawable/ic_network_warning"
app:singleLineTitle="false" />
<SwitchPreferenceCompat
android:id="@+id/d2d_backup_preference"
android:defaultValue="true"
android:key="scheduling_charging"
android:title="@string/settings_scheduling_charging_title"
app:icon="@drawable/ic_battery_charging_full"
app:singleLineTitle="false" />
</PreferenceCategory>
</PreferenceScreen>

View file

@ -19,6 +19,8 @@ import org.koin.dsl.module
class TestApp : App() { class TestApp : App() {
override val isTest: Boolean = true
private val testCryptoModule = module { private val testCryptoModule = module {
factory<CipherFactory> { CipherFactoryImpl(get()) } factory<CipherFactory> { CipherFactoryImpl(get()) }
single<KeyManager> { KeyManagerTestImpl() } single<KeyManager> { KeyManagerTestImpl() }

View file

@ -28,10 +28,12 @@ import io.mockk.verify
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assert.fail import org.junit.Assert.fail
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.jupiter.api.assertThrows
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.koin.core.context.stopKoin import org.koin.core.context.stopKoin
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@ -98,7 +100,7 @@ class MetadataManagerTest {
expectReadFromCache() expectReadFromCache()
expectModifyMetadata(initialMetadata) expectModifyMetadata(initialMetadata)
manager.onDeviceInitialization(token, storageOutputStream) manager.onDeviceInitialization(token)
assertEquals(token, manager.getBackupToken()) assertEquals(token, manager.getBackupToken())
assertEquals(0L, manager.getLastBackupTime()) assertEquals(0L, manager.getLastBackupTime())
@ -121,7 +123,7 @@ class MetadataManagerTest {
expectReadFromCache() expectReadFromCache()
expectModifyMetadata(initialMetadata) expectModifyMetadata(initialMetadata)
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream) manager.onApkBackedUp(packageInfo, packageMetadata)
assertEquals(packageMetadata, manager.getPackageMetadata(packageName)) assertEquals(packageMetadata, manager.getPackageMetadata(packageName))
@ -144,7 +146,7 @@ class MetadataManagerTest {
expectReadFromCache() expectReadFromCache()
expectModifyMetadata(initialMetadata) expectModifyMetadata(initialMetadata)
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream) manager.onApkBackedUp(packageInfo, packageMetadata)
assertEquals(packageMetadata.copy(system = true), manager.getPackageMetadata(packageName)) assertEquals(packageMetadata.copy(system = true), manager.getPackageMetadata(packageName))
@ -171,9 +173,9 @@ class MetadataManagerTest {
) )
expectReadFromCache() expectReadFromCache()
expectModifyMetadata(initialMetadata) expectWriteToCache(initialMetadata)
manager.onApkBackedUp(packageInfo, updatedPackageMetadata, storageOutputStream) manager.onApkBackedUp(packageInfo, updatedPackageMetadata)
assertEquals(updatedPackageMetadata, manager.getPackageMetadata(packageName)) assertEquals(updatedPackageMetadata, manager.getPackageMetadata(packageName))
@ -184,7 +186,7 @@ class MetadataManagerTest {
} }
@Test @Test
fun `test onApkBackedUp() limits state changes`() { fun `test onApkBackedUp() does not change package state`() {
var version = Random.nextLong(Long.MAX_VALUE) var version = Random.nextLong(Long.MAX_VALUE)
var packageMetadata = PackageMetadata( var packageMetadata = PackageMetadata(
version = version, version = version,
@ -193,12 +195,12 @@ class MetadataManagerTest {
) )
expectReadFromCache() expectReadFromCache()
expectModifyMetadata(initialMetadata) expectWriteToCache(initialMetadata)
val oldState = UNKNOWN_ERROR val oldState = UNKNOWN_ERROR
// state doesn't change for APK_AND_DATA // state doesn't change for APK_AND_DATA
packageMetadata = packageMetadata.copy(version = ++version, state = APK_AND_DATA) packageMetadata = packageMetadata.copy(version = ++version, state = APK_AND_DATA)
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream) manager.onApkBackedUp(packageInfo, packageMetadata)
assertEquals( assertEquals(
packageMetadata.copy(state = oldState), packageMetadata.copy(state = oldState),
manager.getPackageMetadata(packageName) manager.getPackageMetadata(packageName)
@ -206,7 +208,7 @@ class MetadataManagerTest {
// state doesn't change for QUOTA_EXCEEDED // state doesn't change for QUOTA_EXCEEDED
packageMetadata = packageMetadata.copy(version = ++version, state = QUOTA_EXCEEDED) packageMetadata = packageMetadata.copy(version = ++version, state = QUOTA_EXCEEDED)
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream) manager.onApkBackedUp(packageInfo, packageMetadata)
assertEquals( assertEquals(
packageMetadata.copy(state = oldState), packageMetadata.copy(state = oldState),
manager.getPackageMetadata(packageName) manager.getPackageMetadata(packageName)
@ -214,25 +216,25 @@ class MetadataManagerTest {
// state doesn't change for NO_DATA // state doesn't change for NO_DATA
packageMetadata = packageMetadata.copy(version = ++version, state = NO_DATA) packageMetadata = packageMetadata.copy(version = ++version, state = NO_DATA)
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream) manager.onApkBackedUp(packageInfo, packageMetadata)
assertEquals( assertEquals(
packageMetadata.copy(state = oldState), packageMetadata.copy(state = oldState),
manager.getPackageMetadata(packageName) manager.getPackageMetadata(packageName)
) )
// state DOES change for NOT_ALLOWED // state doesn't change for NOT_ALLOWED
packageMetadata = packageMetadata.copy(version = ++version, state = NOT_ALLOWED) packageMetadata = packageMetadata.copy(version = ++version, state = NOT_ALLOWED)
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream) manager.onApkBackedUp(packageInfo, packageMetadata)
assertEquals( assertEquals(
packageMetadata.copy(state = NOT_ALLOWED), packageMetadata.copy(state = oldState),
manager.getPackageMetadata(packageName) manager.getPackageMetadata(packageName)
) )
// state DOES change for WAS_STOPPED // state doesn't change for WAS_STOPPED
packageMetadata = packageMetadata.copy(version = ++version, state = WAS_STOPPED) packageMetadata = packageMetadata.copy(version = ++version, state = WAS_STOPPED)
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream) manager.onApkBackedUp(packageInfo, packageMetadata)
assertEquals( assertEquals(
packageMetadata.copy(state = WAS_STOPPED), packageMetadata.copy(state = oldState),
manager.getPackageMetadata(packageName) 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<IOException> {
manager.onApkBackedUp(packageInfo, packageMetadata)
}
// metadata change got reverted
assertNull(manager.getPackageMetadata(packageName))
verify {
cacheInputStream.close()
}
}
@Test @Test
fun `test onPackageBackedUp()`() { fun `test onPackageBackedUp()`() {
packageInfo.applicationInfo.flags = FLAG_SYSTEM packageInfo.applicationInfo.flags = FLAG_SYSTEM
@ -317,10 +352,7 @@ class MetadataManagerTest {
} }
assertEquals(0L, manager.getLastBackupTime()) // time was reverted assertEquals(0L, manager.getLastBackupTime()) // time was reverted
assertEquals( assertNull(manager.getPackageMetadata(packageName)) // no package metadata got added
initialMetadata.packageMetadataMap[packageName],
manager.getPackageMetadata(packageName)
)
verify { cacheInputStream.close() } 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 @Test
fun `test getBackupToken() on first run`() { fun `test getBackupToken() on first run`() {
every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException() every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException()
@ -386,15 +482,7 @@ class MetadataManagerTest {
private fun expectModifyMetadata(metadata: BackupMetadata) { private fun expectModifyMetadata(metadata: BackupMetadata) {
every { metadataWriter.write(metadata, storageOutputStream) } just Runs every { metadataWriter.write(metadata, storageOutputStream) } just Runs
every { metadataWriter.encode(metadata) } returns encodedMetadata expectWriteToCache(metadata)
every {
context.openFileOutput(
METADATA_CACHE_FILE,
MODE_PRIVATE
)
} returns cacheOutputStream
every { cacheOutputStream.write(encodedMetadata) } just Runs
every { cacheOutputStream.close() } just Runs
} }
private fun expectReadFromCache() { private fun expectReadFromCache() {
@ -406,4 +494,16 @@ class MetadataManagerTest {
every { cacheInputStream.close() } just Runs 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
}
} }

View file

@ -10,12 +10,11 @@ import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.metadata.PackageState
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.restore.RestorableBackup import com.stevesoltys.seedvault.restore.RestorableBackup
import com.stevesoltys.seedvault.transport.TransportTest 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.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -121,7 +120,7 @@ internal class ApkBackupRestoreTest : TransportTest() {
every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName
every { storagePlugin.providerPackageName } returns storageProviderPackageName every { storagePlugin.providerPackageName } returns storageProviderPackageName
apkBackup.backupApkIfNecessary(packageInfo, PackageState.APK_AND_DATA, outputStreamGetter) apkBackup.backupApkIfNecessary(packageInfo, outputStreamGetter)
assertArrayEquals(apkBytes, outputStream.toByteArray()) assertArrayEquals(apkBytes, outputStream.toByteArray())
assertArrayEquals(splitBytes, splitOutputStream.toByteArray()) assertArrayEquals(splitBytes, splitOutputStream.toByteArray())

View file

@ -15,11 +15,9 @@ import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH
import com.stevesoltys.seedvault.metadata.BackupType import com.stevesoltys.seedvault.metadata.BackupType
import com.stevesoltys.seedvault.metadata.MetadataReaderImpl import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
import com.stevesoltys.seedvault.metadata.PackageMetadata 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.LegacyStoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA 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.BackupCoordinator
import com.stevesoltys.seedvault.transport.backup.FullBackup import com.stevesoltys.seedvault.transport.backup.FullBackup
import com.stevesoltys.seedvault.transport.backup.InputFactory 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.OutputFactory
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.worker.ApkBackup
import io.mockk.CapturingSlot import io.mockk.CapturingSlot
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery import io.mockk.coEvery
@ -73,7 +72,6 @@ internal class CoordinatorIntegrationTest : TransportTest() {
backupPlugin, backupPlugin,
kvBackup, kvBackup,
fullBackup, fullBackup,
apkBackup,
clock, clock,
packageService, packageService,
metadataManager, metadataManager,
@ -138,13 +136,13 @@ internal class CoordinatorIntegrationTest : TransportTest() {
appData2.size appData2.size
} }
coEvery { coEvery {
apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) apkBackup.backupApkIfNecessary(packageInfo, any())
} returns packageMetadata } returns packageMetadata
coEvery { coEvery {
backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA) backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA)
} returns metadataOutputStream } returns metadataOutputStream
every { every {
metadataManager.onApkBackedUp(packageInfo, packageMetadata, metadataOutputStream) metadataManager.onApkBackedUp(packageInfo, packageMetadata)
} just Runs } just Runs
every { every {
metadataManager.onPackageBackedUp( metadataManager.onPackageBackedUp(
@ -215,7 +213,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
appData.copyInto(value.captured) // write the app data into the passed ByteArray appData.copyInto(value.captured) // write the app data into the passed ByteArray
appData.size appData.size
} }
coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns null
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
coEvery { coEvery {
backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA) backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA)
@ -279,25 +277,13 @@ internal class CoordinatorIntegrationTest : TransportTest() {
coEvery { backupPlugin.getOutputStream(token, realName) } returns bOutputStream coEvery { backupPlugin.getOutputStream(token, realName) } returns bOutputStream
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
every { settingsManager.isQuotaUnlimited() } returns false every { settingsManager.isQuotaUnlimited() } returns false
coEvery { coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns packageMetadata
apkBackup.backupApkIfNecessary(
packageInfo,
UNKNOWN_ERROR,
any()
)
} returns packageMetadata
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
every { metadataManager.salt } returns salt every { metadataManager.salt } returns salt
coEvery { coEvery {
backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA) backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA)
} returns metadataOutputStream } returns metadataOutputStream
every { every { metadataManager.onApkBackedUp(packageInfo, packageMetadata) } just Runs
metadataManager.onApkBackedUp(
packageInfo,
packageMetadata,
metadataOutputStream
)
} just Runs
every { every {
metadataManager.onPackageBackedUp( metadataManager.onPackageBackedUp(
packageInfo = packageInfo, packageInfo = packageInfo,

View file

@ -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_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
import android.content.pm.ApplicationInfo.FLAG_STOPPED
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.net.Uri import android.net.Uri
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
@ -14,18 +13,15 @@ import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.BackupType import com.stevesoltys.seedvault.metadata.BackupType
import com.stevesoltys.seedvault.metadata.PackageMetadata 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.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED 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.StoragePlugin
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.settings.Storage import com.stevesoltys.seedvault.settings.Storage
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.worker.ApkBackup
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
@ -36,7 +32,6 @@ import org.junit.jupiter.api.Test
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
import kotlin.random.Random import kotlin.random.Random
import kotlin.random.nextLong
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
internal class BackupCoordinatorTest : BackupTest() { internal class BackupCoordinatorTest : BackupTest() {
@ -53,7 +48,6 @@ internal class BackupCoordinatorTest : BackupTest() {
plugin, plugin,
kv, kv,
full, full,
apkBackup,
clock, clock,
packageService, packageService,
metadataManager, metadataManager,
@ -75,22 +69,18 @@ internal class BackupCoordinatorTest : BackupTest() {
fun `device initialization succeeds and delegates to plugin`() = runBlocking { fun `device initialization succeeds and delegates to plugin`() = runBlocking {
expectStartNewRestoreSet() expectStartNewRestoreSet()
coEvery { plugin.initializeDevice() } just Runs 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 { kv.hasState() } returns false
every { full.hasState() } returns false every { full.hasState() } returns false
every { metadataOutputStream.close() } just Runs
assertEquals(TRANSPORT_OK, backup.initializeDevice()) assertEquals(TRANSPORT_OK, backup.initializeDevice())
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertEquals(TRANSPORT_OK, backup.finishBackup())
verify { metadataOutputStream.close() }
} }
private suspend fun expectStartNewRestoreSet() { private suspend fun expectStartNewRestoreSet() {
every { clock.time() } returns token every { clock.time() } returns token
every { settingsManager.setNewToken(token) } just Runs every { settingsManager.setNewToken(token) } just Runs
coEvery { plugin.startNewRestoreSet(token) } just Runs coEvery { plugin.startNewRestoreSet(token) } just Runs
every { metadataManager.onDeviceInitialization(token) } just Runs
} }
@Test @Test
@ -142,6 +132,7 @@ internal class BackupCoordinatorTest : BackupTest() {
every { clock.time() } returns token + 1 every { clock.time() } returns token + 1
every { settingsManager.setNewToken(token + 1) } just Runs every { settingsManager.setNewToken(token + 1) } just Runs
coEvery { plugin.startNewRestoreSet(token + 1) } just Runs coEvery { plugin.startNewRestoreSet(token + 1) } just Runs
every { metadataManager.onDeviceInitialization(token + 1) } just Runs
every { data.close() } just Runs every { data.close() } just Runs
@ -157,16 +148,12 @@ internal class BackupCoordinatorTest : BackupTest() {
val isFullBackup = Random.nextBoolean() val isFullBackup = Random.nextBoolean()
val quota = Random.nextLong() val quota = Random.nextLong()
expectApkBackupAndMetadataWrite()
if (isFullBackup) { if (isFullBackup) {
every { full.getQuota() } returns quota every { full.getQuota() } returns quota
} else { } else {
every { kv.getQuota() } returns quota every { kv.getQuota() } returns quota
} }
every { metadataOutputStream.close() } just Runs
assertEquals(quota, backup.getBackupQuota(packageInfo.packageName, isFullBackup)) assertEquals(quota, backup.getBackupQuota(packageInfo.packageName, isFullBackup))
verify { metadataOutputStream.close() }
} }
@Test @Test
@ -276,7 +263,7 @@ internal class BackupCoordinatorTest : BackupTest() {
coEvery { coEvery {
full.performFullBackup(packageInfo, fileDescriptor, 0, token, salt) full.performFullBackup(packageInfo, fileDescriptor, 0, token, salt)
} returns TRANSPORT_OK } 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)) assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0))
} }
@ -304,6 +291,7 @@ internal class BackupCoordinatorTest : BackupTest() {
} just Runs } just Runs
coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs
every { settingsManager.getStorage() } returns storage every { settingsManager.getStorage() } returns storage
every { settingsManager.useMeteredNetwork } returns false
every { metadataOutputStream.close() } just Runs every { metadataOutputStream.close() } just Runs
assertEquals( assertEquals(
@ -353,6 +341,7 @@ internal class BackupCoordinatorTest : BackupTest() {
} just Runs } just Runs
coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs
every { settingsManager.getStorage() } returns storage every { settingsManager.getStorage() } returns storage
every { settingsManager.useMeteredNetwork } returns false
every { metadataOutputStream.close() } just Runs every { metadataOutputStream.close() } just Runs
assertEquals( assertEquals(
@ -380,180 +369,13 @@ internal class BackupCoordinatorTest : BackupTest() {
@Test @Test
fun `not allowed apps get their APKs backed up after @pm@ backup`() = runBlocking { 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() { private fun expectApkBackupAndMetadataWrite() {
coEvery { coEvery { apkBackup.backupApkIfNecessary(any(), any()) } returns packageMetadata
apkBackup.backupApkIfNecessary(
any(),
UNKNOWN_ERROR,
any()
)
} returns packageMetadata
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
every { every { metadataManager.onApkBackedUp(any(), packageMetadata) } just Runs
metadataManager.onApkBackedUp(
any(),
packageMetadata,
metadataOutputStream
)
} just Runs
} }
} }

View file

@ -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<OutputStream>()
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<ApplicationInfo> {
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
}
}

View file

@ -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_SYSTEM
import android.content.pm.ApplicationInfo.FLAG_TEST_ONLY 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.ApkSplit
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.transport.backup.BackupTest
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -56,7 +62,7 @@ internal class ApkBackupTest : BackupTest() {
@Test @Test
fun `does not back up @pm@`() = runBlocking { fun `does not back up @pm@`() = runBlocking {
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER } val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)) assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
} }
@Test @Test
@ -64,7 +70,7 @@ internal class ApkBackupTest : BackupTest() {
every { settingsManager.backupApks() } returns false every { settingsManager.backupApks() } returns false
every { settingsManager.isBackupEnabled(any()) } returns true every { settingsManager.isBackupEnabled(any()) } returns true
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)) assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
} }
@Test @Test
@ -72,7 +78,7 @@ internal class ApkBackupTest : BackupTest() {
every { settingsManager.backupApks() } returns true every { settingsManager.backupApks() } returns true
every { settingsManager.isBackupEnabled(any()) } returns false every { settingsManager.isBackupEnabled(any()) } returns false
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)) assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
} }
@Test @Test
@ -81,7 +87,7 @@ internal class ApkBackupTest : BackupTest() {
every { settingsManager.isBackupEnabled(any()) } returns true every { settingsManager.isBackupEnabled(any()) } returns true
every { settingsManager.backupApks() } returns true every { settingsManager.backupApks() } returns true
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)) assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
} }
@Test @Test
@ -90,7 +96,7 @@ internal class ApkBackupTest : BackupTest() {
every { settingsManager.isBackupEnabled(any()) } returns true every { settingsManager.isBackupEnabled(any()) } returns true
every { settingsManager.backupApks() } returns true every { settingsManager.backupApks() } returns true
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)) assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
} }
@Test @Test
@ -102,7 +108,7 @@ internal class ApkBackupTest : BackupTest() {
expectChecks(packageMetadata) expectChecks(packageMetadata)
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)) assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
} }
@Test @Test
@ -113,7 +119,7 @@ internal class ApkBackupTest : BackupTest() {
assertThrows(IOException::class.java) { assertThrows(IOException::class.java) {
runBlocking { 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.hasMultipleSigners() } returns false
every { sigInfo.signingCertificateHistory } returns emptyArray() every { sigInfo.signingCertificateHistory } returns emptyArray()
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)) assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
} }
@Test @Test
@ -141,7 +147,7 @@ internal class ApkBackupTest : BackupTest() {
}.absolutePath }.absolutePath
val apkOutputStream = ByteArrayOutputStream() val apkOutputStream = ByteArrayOutputStream()
val updatedMetadata = PackageMetadata( val updatedMetadata = PackageMetadata(
time = 0L, time = packageMetadata.time,
state = UNKNOWN_ERROR, state = UNKNOWN_ERROR,
version = packageInfo.longVersionCode, version = packageInfo.longVersionCode,
installer = getRandomString(), installer = getRandomString(),
@ -159,7 +165,7 @@ internal class ApkBackupTest : BackupTest() {
assertEquals( assertEquals(
updatedMetadata, updatedMetadata,
apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter) apkBackup.backupApkIfNecessary(packageInfo, streamGetter)
) )
assertArrayEquals(apkBytes, apkOutputStream.toByteArray()) assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
} }
@ -198,7 +204,7 @@ internal class ApkBackupTest : BackupTest() {
val split2OutputStream = ByteArrayOutputStream() val split2OutputStream = ByteArrayOutputStream()
// expected new metadata for package // expected new metadata for package
val updatedMetadata = PackageMetadata( val updatedMetadata = PackageMetadata(
time = 0L, time = packageMetadata.time,
state = UNKNOWN_ERROR, state = UNKNOWN_ERROR,
version = packageInfo.longVersionCode, version = packageInfo.longVersionCode,
installer = getRandomString(), installer = getRandomString(),
@ -231,7 +237,7 @@ internal class ApkBackupTest : BackupTest() {
assertEquals( assertEquals(
updatedMetadata, updatedMetadata,
apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter) apkBackup.backupApkIfNecessary(packageInfo, streamGetter)
) )
assertArrayEquals(apkBytes, apkOutputStream.toByteArray()) assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
assertArrayEquals(split1Bytes, split1OutputStream.toByteArray()) assertArrayEquals(split1Bytes, split1OutputStream.toByteArray())