Merge pull request #628 from grote/583-scheduling
Move to our own scheduling
This commit is contained in:
commit
6caa01f8c5
48 changed files with 1529 additions and 799 deletions
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
|
@ -18,7 +18,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
android_target: [ 33, 34 ]
|
||||
android_target: [ 34 ]
|
||||
emulator_type: [ aosp_atd ]
|
||||
d2d_backup_test: [ true, false ]
|
||||
steps:
|
||||
|
@ -40,7 +40,7 @@ jobs:
|
|||
- name: Run tests
|
||||
uses: Wandalen/wretry.action@v1.3.0
|
||||
with:
|
||||
attempt_limit: 3
|
||||
attempt_limit: 1
|
||||
action: reactivecircus/android-emulator-runner@v2
|
||||
with: |
|
||||
api-level: ${{ matrix.android_target }}
|
||||
|
|
|
@ -97,4 +97,7 @@ $ADB shell mkdir -p /sdcard/seedvault_baseline
|
|||
$ADB shell tar xzf /sdcard/backup.tar.gz --directory=/sdcard/seedvault_baseline
|
||||
$ADB shell rm /sdcard/backup.tar.gz
|
||||
|
||||
# sometimes a system dialog (e.g. launcher stopped) is showing and taking focus
|
||||
$ADB shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS
|
||||
|
||||
echo "Emulator '$EMULATOR_NAME' has been provisioned with Seedvault!"
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.e2e
|
|||
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.test.uiautomator.Until
|
||||
import com.stevesoltys.seedvault.e2e.io.BackupDataInputIntercept
|
||||
import com.stevesoltys.seedvault.e2e.io.InputStreamIntercept
|
||||
import com.stevesoltys.seedvault.e2e.screen.impl.BackupScreen
|
||||
|
@ -44,6 +45,11 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
|||
if (!backupManager.isBackupEnabled) {
|
||||
backupSwitch.click()
|
||||
waitUntilIdle()
|
||||
|
||||
BackupScreen {
|
||||
device.wait(Until.hasObject(initializingText), 10000)
|
||||
device.wait(Until.gone(initializingText), 120000)
|
||||
}
|
||||
}
|
||||
|
||||
backupMenu.clickAndWaitForNewWindow()
|
||||
|
@ -179,7 +185,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
|||
clearMocks(spyBackupNotificationManager)
|
||||
|
||||
every {
|
||||
spyBackupNotificationManager.onBackupFinished(any(), any(), any())
|
||||
spyBackupNotificationManager.onBackupFinished(any(), any(), any(), any())
|
||||
} answers {
|
||||
val success = firstArg<Boolean>()
|
||||
assert(success) { "Backup failed." }
|
||||
|
|
|
@ -156,6 +156,11 @@
|
|||
</intent-filter>
|
||||
</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 -->
|
||||
<service
|
||||
android:name=".storage.StorageBackupJobService"
|
||||
|
|
|
@ -9,7 +9,11 @@ import android.content.pm.PackageManager.PERMISSION_GRANTED
|
|||
import android.os.Build
|
||||
import android.os.ServiceManager.getService
|
||||
import android.os.StrictMode
|
||||
import android.os.UserHandle
|
||||
import android.os.UserManager
|
||||
import android.provider.Settings
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
|
||||
import com.stevesoltys.seedvault.crypto.cryptoModule
|
||||
import com.stevesoltys.seedvault.header.headerModule
|
||||
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.storage.BackupStorageViewModel
|
||||
import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel
|
||||
import com.stevesoltys.seedvault.worker.AppBackupWorker
|
||||
import com.stevesoltys.seedvault.worker.workerModule
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.android.ext.koin.androidLogger
|
||||
|
@ -42,6 +48,8 @@ import org.koin.dsl.module
|
|||
*/
|
||||
open class App : Application() {
|
||||
|
||||
open val isTest: Boolean = false
|
||||
|
||||
private val appModule = module {
|
||||
single { SettingsManager(this@App) }
|
||||
single { BackupNotificationManager(this@App) }
|
||||
|
@ -78,6 +86,7 @@ open class App : Application() {
|
|||
permitDiskReads {
|
||||
migrateTokenFromMetadataToSettingsManager()
|
||||
}
|
||||
if (!isTest) migrateToOwnScheduling()
|
||||
}
|
||||
|
||||
protected open fun startKoin() = startKoin {
|
||||
|
@ -95,11 +104,13 @@ open class App : Application() {
|
|||
restoreModule,
|
||||
installModule,
|
||||
storageModule,
|
||||
workerModule,
|
||||
appModule
|
||||
)
|
||||
|
||||
private val settingsManager: SettingsManager by inject()
|
||||
private val metadataManager: MetadataManager by inject()
|
||||
private val backupManager: IBackupManager by inject()
|
||||
|
||||
/**
|
||||
* The responsibility for the current token was moved to the [SettingsManager]
|
||||
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -20,15 +20,13 @@ import com.stevesoltys.seedvault.settings.FlashDrive
|
|||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.storage.StorageBackupService
|
||||
import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP
|
||||
import com.stevesoltys.seedvault.transport.requestBackup
|
||||
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
|
||||
import com.stevesoltys.seedvault.worker.AppBackupWorker
|
||||
import org.koin.core.context.GlobalContext.get
|
||||
import java.util.concurrent.TimeUnit.HOURS
|
||||
import java.util.Date
|
||||
|
||||
private val TAG = UsbIntentReceiver::class.java.simpleName
|
||||
|
||||
private const val HOURS_AUTO_BACKUP: Long = 24
|
||||
|
||||
class UsbIntentReceiver : UsbMonitor() {
|
||||
|
||||
// using KoinComponent would crash robolectric tests :(
|
||||
|
@ -43,11 +41,13 @@ class UsbIntentReceiver : UsbMonitor() {
|
|||
return if (savedFlashDrive == attachedFlashDrive) {
|
||||
Log.d(TAG, "Matches stored device, checking backup time...")
|
||||
val backupMillis = System.currentTimeMillis() - metadataManager.getLastBackupTime()
|
||||
if (backupMillis >= HOURS.toMillis(HOURS_AUTO_BACKUP)) {
|
||||
Log.d(TAG, "Last backup older than 24 hours, requesting a backup...")
|
||||
if (backupMillis >= settingsManager.backupFrequencyInMillis) {
|
||||
Log.d(TAG, "Last backup older than it should be, requesting a backup...")
|
||||
Log.d(TAG, " ${Date(metadataManager.getLastBackupTime())}")
|
||||
true
|
||||
} else {
|
||||
Log.d(TAG, "We have a recent backup, not requesting a new one.")
|
||||
Log.d(TAG, " ${Date(metadataManager.getLastBackupTime())}")
|
||||
false
|
||||
}
|
||||
} else {
|
||||
|
@ -63,9 +63,7 @@ class UsbIntentReceiver : UsbMonitor() {
|
|||
i.putExtra(EXTRA_START_APP_BACKUP, true)
|
||||
startForegroundService(context, i)
|
||||
} else {
|
||||
Thread {
|
||||
requestBackup(context)
|
||||
}.start()
|
||||
AppBackupWorker.scheduleNow(context, reschedule = false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,9 +14,6 @@ import com.stevesoltys.seedvault.crypto.Crypto
|
|||
import com.stevesoltys.seedvault.encodeBase64
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
||||
import java.io.FileNotFoundException
|
||||
|
@ -36,7 +33,7 @@ internal class MetadataManager(
|
|||
private val crypto: Crypto,
|
||||
private val metadataWriter: MetadataWriter,
|
||||
private val metadataReader: MetadataReader,
|
||||
private val settingsManager: SettingsManager
|
||||
private val settingsManager: SettingsManager,
|
||||
) {
|
||||
|
||||
private val uninitializedMetadata = BackupMetadata(token = 0L, salt = "")
|
||||
|
@ -61,14 +58,15 @@ internal class MetadataManager(
|
|||
/**
|
||||
* Call this when initializing a new device.
|
||||
*
|
||||
* Existing [BackupMetadata] will be cleared, use the given new token,
|
||||
* and written encrypted to the given [OutputStream] as well as the internal cache.
|
||||
* Existing [BackupMetadata] will be cleared
|
||||
* and new metadata with the given [token] will be written to the internal cache
|
||||
* with a fresh salt.
|
||||
*/
|
||||
@Synchronized
|
||||
@Throws(IOException::class)
|
||||
fun onDeviceInitialization(token: Long, metadataOutputStream: OutputStream) {
|
||||
fun onDeviceInitialization(token: Long) {
|
||||
val salt = crypto.getRandomBytes(METADATA_SALT_SIZE).encodeBase64()
|
||||
modifyMetadata(metadataOutputStream) {
|
||||
modifyCachedMetadata {
|
||||
metadata = BackupMetadata(token = token, salt = salt)
|
||||
}
|
||||
}
|
||||
|
@ -76,42 +74,25 @@ internal class MetadataManager(
|
|||
/**
|
||||
* Call this after a package's APK has been backed up successfully.
|
||||
*
|
||||
* It updates the packages' metadata
|
||||
* and writes it encrypted to the given [OutputStream] as well as the internal cache.
|
||||
*
|
||||
* Closing the [OutputStream] is the responsibility of the caller.
|
||||
* It updates the packages' metadata to the internal cache.
|
||||
* You still need to call [uploadMetadata] to persist all local modifications.
|
||||
*/
|
||||
@Synchronized
|
||||
@Throws(IOException::class)
|
||||
fun onApkBackedUp(
|
||||
packageInfo: PackageInfo,
|
||||
packageMetadata: PackageMetadata,
|
||||
metadataOutputStream: OutputStream,
|
||||
) {
|
||||
val packageName = packageInfo.packageName
|
||||
metadata.packageMetadataMap[packageName]?.let {
|
||||
check(packageMetadata.version != null) {
|
||||
"APK backup returned version null"
|
||||
}
|
||||
check(it.version == null || it.version < packageMetadata.version) {
|
||||
"APK backup backed up the same or a smaller version:" +
|
||||
"was ${it.version} is ${packageMetadata.version}"
|
||||
}
|
||||
}
|
||||
val oldPackageMetadata = metadata.packageMetadataMap[packageName]
|
||||
?: PackageMetadata()
|
||||
// only allow state change if backup of this package is not allowed,
|
||||
// because we need to change from the default of UNKNOWN_ERROR here,
|
||||
// but otherwise don't want to modify the state since set elsewhere.
|
||||
val newState =
|
||||
if (packageMetadata.state == NOT_ALLOWED || packageMetadata.state == WAS_STOPPED) {
|
||||
packageMetadata.state
|
||||
} else {
|
||||
oldPackageMetadata.state
|
||||
}
|
||||
modifyMetadata(metadataOutputStream) {
|
||||
modifyCachedMetadata {
|
||||
metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy(
|
||||
state = newState,
|
||||
system = packageInfo.isSystemApp(),
|
||||
version = packageMetadata.version,
|
||||
installer = packageMetadata.installer,
|
||||
|
@ -143,21 +124,20 @@ internal class MetadataManager(
|
|||
val now = clock.time()
|
||||
metadata.time = now
|
||||
metadata.d2dBackup = settingsManager.d2dBackupsEnabled()
|
||||
|
||||
if (metadata.packageMetadataMap.containsKey(packageName)) {
|
||||
metadata.packageMetadataMap[packageName]!!.time = now
|
||||
metadata.packageMetadataMap[packageName]!!.state = APK_AND_DATA
|
||||
metadata.packageMetadataMap[packageName]!!.backupType = type
|
||||
// don't override a previous K/V size, if there were no K/V changes
|
||||
if (size != null) metadata.packageMetadataMap[packageName]!!.size = size
|
||||
} else {
|
||||
metadata.packageMetadataMap[packageName] = PackageMetadata(
|
||||
metadata.packageMetadataMap.getOrPut(packageName) {
|
||||
PackageMetadata(
|
||||
time = now,
|
||||
state = APK_AND_DATA,
|
||||
backupType = type,
|
||||
size = size,
|
||||
system = packageInfo.isSystemApp(),
|
||||
)
|
||||
}.apply {
|
||||
time = now
|
||||
state = APK_AND_DATA
|
||||
backupType = type
|
||||
// don't override a previous K/V size, if there were no K/V changes
|
||||
if (size != null) this.size = size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -177,24 +157,69 @@ internal class MetadataManager(
|
|||
backupType: BackupType? = null,
|
||||
) {
|
||||
check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." }
|
||||
val packageName = packageInfo.packageName
|
||||
modifyMetadata(metadataOutputStream) {
|
||||
if (metadata.packageMetadataMap.containsKey(packageName)) {
|
||||
metadata.packageMetadataMap[packageName]!!.state = packageState
|
||||
} else {
|
||||
metadata.packageMetadataMap[packageName] = PackageMetadata(
|
||||
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
|
||||
PackageMetadata(
|
||||
time = 0L,
|
||||
state = packageState,
|
||||
backupType = backupType,
|
||||
system = packageInfo.isSystemApp()
|
||||
)
|
||||
}
|
||||
}.state = packageState
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this for all packages we can not back up for some reason.
|
||||
*
|
||||
* It updates the packages' local metadata.
|
||||
* You still need to call [uploadMetadata] to persist all local modifications.
|
||||
*/
|
||||
@Synchronized
|
||||
@Throws(IOException::class)
|
||||
internal fun onPackageDoesNotGetBackedUp(
|
||||
packageInfo: PackageInfo,
|
||||
packageState: PackageState,
|
||||
) = modifyCachedMetadata {
|
||||
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
|
||||
PackageMetadata(
|
||||
time = 0L,
|
||||
state = packageState,
|
||||
system = packageInfo.isSystemApp(),
|
||||
)
|
||||
}.state = packageState
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads metadata to given [metadataOutputStream] after performing local modifications.
|
||||
*/
|
||||
@Synchronized
|
||||
@Throws(IOException::class)
|
||||
fun uploadMetadata(metadataOutputStream: OutputStream) {
|
||||
metadataWriter.write(metadata, metadataOutputStream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun modifyCachedMetadata(modFun: () -> Unit) {
|
||||
val oldMetadata = metadata.copy( // copy map, otherwise it will re-use same reference
|
||||
packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap),
|
||||
)
|
||||
try {
|
||||
modFun.invoke()
|
||||
writeMetadataToCache()
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error writing metadata to storage", e)
|
||||
// revert metadata and do not write it to cache
|
||||
metadata = oldMetadata
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun modifyMetadata(metadataOutputStream: OutputStream, modFun: () -> Unit) {
|
||||
val oldMetadata = metadata.copy()
|
||||
val oldMetadata = metadata.copy( // copy map, otherwise it will re-use same reference
|
||||
packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap),
|
||||
)
|
||||
try {
|
||||
modFun.invoke()
|
||||
metadataWriter.write(metadata, metadataOutputStream)
|
||||
|
@ -242,18 +267,6 @@ internal class MetadataManager(
|
|||
return metadata.packageMetadataMap[packageName]?.copy()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getPackagesNumBackedUp(): Int {
|
||||
// FIXME we are under-reporting packages here,
|
||||
// because we have no way to also include upgraded system apps
|
||||
return metadata.packageMetadataMap.filter { (_, packageMetadata) ->
|
||||
!packageMetadata.system && ( // ignore system apps
|
||||
packageMetadata.state == APK_AND_DATA || // either full success
|
||||
packageMetadata.state == NO_DATA // or apps that simply had no data
|
||||
)
|
||||
}.count()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getPackagesBackupSize(): Long {
|
||||
return metadata.packageMetadataMap.values.sumOf { it.size ?: 0L }
|
||||
|
|
|
@ -35,22 +35,13 @@ internal class DocumentsProviderStoragePlugin(
|
|||
override suspend fun startNewRestoreSet(token: Long) {
|
||||
// reset current storage
|
||||
storage.reset(token)
|
||||
|
||||
// get or create root backup dir
|
||||
storage.rootBackupDir ?: throw IOException()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override suspend fun initializeDevice() {
|
||||
// wipe existing data
|
||||
storage.getSetDir()?.deleteContents(context)
|
||||
|
||||
// reset storage without new token, so folders get recreated
|
||||
// otherwise stale DocumentFiles will hang around
|
||||
storage.reset(null)
|
||||
|
||||
// create backup folders
|
||||
storage.currentSetDir ?: throw IOException()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
|
|
|
@ -39,7 +39,7 @@ import com.stevesoltys.seedvault.restore.install.isInstalled
|
|||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.storage.StorageRestoreService
|
||||
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
|
||||
import com.stevesoltys.seedvault.transport.backup.NUM_PACKAGES_PER_TRANSACTION
|
||||
import com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION
|
||||
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
|
||||
|
|
|
@ -15,9 +15,9 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_A
|
|||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
|
||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
|
||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
|
||||
import com.stevesoltys.seedvault.transport.backup.copyStreamsAndGetHash
|
||||
import com.stevesoltys.seedvault.transport.backup.getSignatures
|
||||
import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
||||
import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash
|
||||
import com.stevesoltys.seedvault.worker.getSignatures
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
@ -38,14 +38,12 @@ internal class ApkRestore(
|
|||
|
||||
private val pm = context.packageManager
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
fun restore(backup: RestorableBackup) = flow {
|
||||
// filter out packages without APK and get total
|
||||
// we don't filter out apps without APK, so the user can manually install them
|
||||
val packages = backup.packageMetadataMap.filter {
|
||||
// We also need to exclude the DocumentsProvider used to retrieve backup data.
|
||||
// We need to exclude the DocumentsProvider used to retrieve backup data.
|
||||
// Otherwise, it gets killed when we install it, terminating our restoration.
|
||||
val isStorageProvider = it.key == storagePlugin.providerPackageName
|
||||
it.value.hasApk() && !isStorageProvider
|
||||
it.key != storagePlugin.providerPackageName
|
||||
}
|
||||
val total = packages.size
|
||||
var progress = 0
|
||||
|
@ -66,7 +64,11 @@ internal class ApkRestore(
|
|||
// re-install individual packages and emit updates
|
||||
for ((packageName, metadata) in packages) {
|
||||
try {
|
||||
restore(this, backup, packageName, metadata, installResult)
|
||||
if (metadata.hasApk()) {
|
||||
restore(this, backup, packageName, metadata, installResult)
|
||||
} else {
|
||||
emit(installResult.fail(packageName))
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error re-installing APK for $packageName.", e)
|
||||
emit(installResult.fail(packageName))
|
||||
|
|
|
@ -5,6 +5,7 @@ import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
|||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import com.google.android.mms.ContentType.TEXT_PLAIN
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.permitDiskReads
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
|
@ -16,10 +17,10 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() {
|
|||
private val viewModel: SettingsViewModel by sharedViewModel()
|
||||
private val packageService: PackageService by inject()
|
||||
|
||||
// TODO set mimeType when upgrading androidx lib
|
||||
private val createFileLauncher = registerForActivityResult(CreateDocument()) { uri ->
|
||||
viewModel.onLogcatUriReceived(uri)
|
||||
}
|
||||
private val createFileLauncher =
|
||||
registerForActivityResult(CreateDocument(TEXT_PLAIN)) { uri ->
|
||||
viewModel.onLogcatUriReceived(uri)
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
permitDiskReads {
|
||||
|
@ -44,8 +45,7 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() {
|
|||
val d2dPreference = findPreference<SwitchPreferenceCompat>(PREF_KEY_D2D_BACKUPS)
|
||||
|
||||
d2dPreference?.setOnPreferenceChangeListener { _, newValue ->
|
||||
viewModel.onD2dChanged(newValue as Boolean)
|
||||
d2dPreference.isChecked = newValue
|
||||
d2dPreference.isChecked = newValue as Boolean
|
||||
|
||||
// automatically enable unlimited quota when enabling D2D backups
|
||||
if (d2dPreference.isChecked) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -10,6 +10,7 @@ import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
|
|||
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.ui.recoverycode.ARG_FOR_NEW_CODE
|
||||
import com.stevesoltys.seedvault.ui.storage.StorageCheckFragment
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
|
@ -36,6 +37,19 @@ class SettingsActivity : RequireProvisioningActivity(), OnPreferenceStartFragmen
|
|||
if (intent?.action == ACTION_APP_STATUS_LIST) {
|
||||
showFragment(AppStatusFragment(), true)
|
||||
}
|
||||
|
||||
// observe initialization and show/remove init fragment
|
||||
// this can happen when enabling backup and storage wasn't initialized
|
||||
viewModel.initEvent.observeEvent(this) { show ->
|
||||
val tag = "INIT"
|
||||
if (show) {
|
||||
val title = getString(R.string.storage_check_fragment_backup_title)
|
||||
showFragment(StorageCheckFragment.newInstance(title), true, tag)
|
||||
} else {
|
||||
val fragment = supportFragmentManager.findFragmentByTag(tag)
|
||||
if (fragment?.isVisible == true) supportFragmentManager.popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
|
|
|
@ -19,12 +19,14 @@ import androidx.preference.Preference
|
|||
import androidx.preference.Preference.OnPreferenceChangeListener
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.TwoStatePreference
|
||||
import androidx.work.WorkInfo
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.permitDiskReads
|
||||
import com.stevesoltys.seedvault.restore.RestoreActivity
|
||||
import com.stevesoltys.seedvault.ui.toRelativeTime
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val TAG = SettingsFragment::class.java.name
|
||||
|
||||
|
@ -39,6 +41,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
private lateinit var apkBackup: TwoStatePreference
|
||||
private lateinit var backupLocation: Preference
|
||||
private lateinit var backupStatus: Preference
|
||||
private lateinit var backupScheduling: Preference
|
||||
private lateinit var backupStorage: TwoStatePreference
|
||||
private lateinit var backupRecoveryCode: Preference
|
||||
|
||||
|
@ -121,12 +124,14 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
return@OnPreferenceChangeListener false
|
||||
}
|
||||
backupStatus = findPreference("backup_status")!!
|
||||
backupScheduling = findPreference("backup_scheduling")!!
|
||||
|
||||
backupStorage = findPreference("backup_storage")!!
|
||||
backupStorage.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
|
||||
val disable = !(newValue as Boolean)
|
||||
// TODO this should really get moved out off the UI layer
|
||||
if (disable) {
|
||||
viewModel.disableStorageBackup()
|
||||
viewModel.cancelFilesBackup()
|
||||
return@OnPreferenceChangeListener true
|
||||
}
|
||||
onEnablingStorageBackup()
|
||||
|
@ -142,6 +147,10 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
viewModel.lastBackupTime.observe(viewLifecycleOwner) { time ->
|
||||
setAppBackupStatusSummary(time)
|
||||
}
|
||||
viewModel.appBackupWorkInfo.observe(viewLifecycleOwner) { workInfo ->
|
||||
viewModel.onWorkerStateChanged()
|
||||
setAppBackupSchedulingSummary(workInfo)
|
||||
}
|
||||
|
||||
val backupFiles: Preference = findPreference("backup_files")!!
|
||||
viewModel.filesSummary.observe(viewLifecycleOwner) { summary ->
|
||||
|
@ -159,6 +168,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
setBackupEnabledState()
|
||||
setBackupLocationSummary()
|
||||
setAutoRestoreState()
|
||||
setAppBackupStatusSummary(viewModel.lastBackupTime.value)
|
||||
setAppBackupSchedulingSummary(viewModel.appBackupWorkInfo.value)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
|
@ -204,7 +215,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
private fun trySetBackupEnabled(enabled: Boolean): Boolean {
|
||||
return try {
|
||||
backupManager.isBackupEnabled = enabled
|
||||
if (enabled) viewModel.enableCallLogBackup()
|
||||
viewModel.onBackupEnabled(enabled)
|
||||
backup.isChecked = enabled
|
||||
true
|
||||
} catch (e: RemoteException) {
|
||||
|
@ -244,10 +255,48 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
backupLocation.summary = storage?.name ?: getString(R.string.settings_backup_location_none)
|
||||
}
|
||||
|
||||
private fun setAppBackupStatusSummary(lastBackupInMillis: Long) {
|
||||
// set time of last backup
|
||||
val lastBackup = lastBackupInMillis.toRelativeTime(requireContext())
|
||||
backupStatus.summary = getString(R.string.settings_backup_status_summary, lastBackup)
|
||||
private fun setAppBackupStatusSummary(lastBackupInMillis: Long?) {
|
||||
if (lastBackupInMillis != null) {
|
||||
// set time of last backup
|
||||
val lastBackup = lastBackupInMillis.toRelativeTime(requireContext())
|
||||
backupStatus.summary = getString(R.string.settings_backup_status_summary, lastBackup)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the summary for scheduling which is information about when the next backup is scheduled.
|
||||
*
|
||||
* It could be that it shows the backup as running,
|
||||
* gives an estimate about when the next run will be or
|
||||
* says that nothing is scheduled which can happen when backup destination is on flash drive.
|
||||
*/
|
||||
private fun setAppBackupSchedulingSummary(workInfo: WorkInfo?) {
|
||||
if (storage?.isUsb == true) {
|
||||
backupScheduling.summary = getString(R.string.settings_backup_status_next_backup_usb)
|
||||
return
|
||||
}
|
||||
if (workInfo == null) return
|
||||
|
||||
val nextScheduleTimeMillis = workInfo.nextScheduleTimeMillis
|
||||
if (workInfo.state == WorkInfo.State.RUNNING) {
|
||||
val text = getString(R.string.notification_title)
|
||||
backupScheduling.summary = getString(R.string.settings_backup_status_next_backup, text)
|
||||
} else if (nextScheduleTimeMillis == Long.MAX_VALUE) {
|
||||
val text = getString(R.string.settings_backup_last_backup_never)
|
||||
backupScheduling.summary = getString(R.string.settings_backup_status_next_backup, text)
|
||||
} else {
|
||||
val diff = System.currentTimeMillis() - nextScheduleTimeMillis
|
||||
val isPast = diff > TimeUnit.MINUTES.toMillis(1)
|
||||
if (isPast) {
|
||||
val text = getString(R.string.settings_backup_status_next_backup_past)
|
||||
backupScheduling.summary =
|
||||
getString(R.string.settings_backup_status_next_backup, text)
|
||||
} else {
|
||||
val text = nextScheduleTimeMillis.toRelativeTime(requireContext())
|
||||
backupScheduling.summary =
|
||||
getString(R.string.settings_backup_status_next_backup_estimate, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEnablingStorageBackup() {
|
||||
|
@ -268,7 +317,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
viewModel.enableStorageBackup()
|
||||
viewModel.scheduleFilesBackup()
|
||||
backupStorage.isChecked = true
|
||||
dialog.dismiss()
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.stevesoltys.seedvault.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
|
@ -17,6 +18,9 @@ import java.util.concurrent.ConcurrentSkipListSet
|
|||
internal const val PREF_KEY_TOKEN = "token"
|
||||
internal const val PREF_KEY_BACKUP_APK = "backup_apk"
|
||||
internal const val PREF_KEY_AUTO_RESTORE = "auto_restore"
|
||||
internal const val PREF_KEY_SCHED_FREQ = "scheduling_frequency"
|
||||
internal const val PREF_KEY_SCHED_METERED = "scheduling_metered"
|
||||
internal const val PREF_KEY_SCHED_CHARGING = "scheduling_charging"
|
||||
|
||||
private const val PREF_KEY_STORAGE_URI = "storageUri"
|
||||
private const val PREF_KEY_STORAGE_NAME = "storageName"
|
||||
|
@ -43,6 +47,14 @@ class SettingsManager(private val context: Context) {
|
|||
@Volatile
|
||||
private var token: Long? = null
|
||||
|
||||
fun registerOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) {
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
fun unregisterOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) {
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* This gets accessed by non-UI threads when saving with [PreferenceManager]
|
||||
* and when [isBackupEnabled] is called during a backup run.
|
||||
|
@ -134,13 +146,24 @@ class SettingsManager(private val context: Context) {
|
|||
fun canDoBackupNow(): Boolean {
|
||||
val storage = getStorage() ?: return false
|
||||
val systemContext = context.getStorageContext { storage.isUsb }
|
||||
return !storage.isUnavailableUsb(systemContext) && !storage.isUnavailableNetwork(context)
|
||||
return !storage.isUnavailableUsb(systemContext) &&
|
||||
!storage.isUnavailableNetwork(context, useMeteredNetwork)
|
||||
}
|
||||
|
||||
fun backupApks(): Boolean {
|
||||
return prefs.getBoolean(PREF_KEY_BACKUP_APK, true)
|
||||
}
|
||||
|
||||
val backupFrequencyInMillis: Long
|
||||
get() {
|
||||
return prefs.getString(PREF_KEY_SCHED_FREQ, "86400000")?.toLongOrNull()
|
||||
?: 86400000 // 24h
|
||||
}
|
||||
val useMeteredNetwork: Boolean
|
||||
get() = prefs.getBoolean(PREF_KEY_SCHED_METERED, false)
|
||||
val backupOnlyWhenCharging: Boolean
|
||||
get() = prefs.getBoolean(PREF_KEY_SCHED_CHARGING, true)
|
||||
|
||||
fun isBackupEnabled(packageName: String) = !blacklistedApps.contains(packageName)
|
||||
|
||||
fun isStorageBackupEnabled() = prefs.getBoolean(PREF_KEY_BACKUP_STORAGE, false)
|
||||
|
@ -186,15 +209,16 @@ data class Storage(
|
|||
* Returns true if this is storage that requires network access,
|
||||
* but it isn't available right now.
|
||||
*/
|
||||
fun isUnavailableNetwork(context: Context): Boolean {
|
||||
return requiresNetwork && !hasUnmeteredInternet(context)
|
||||
fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean {
|
||||
return requiresNetwork && !hasUnmeteredInternet(context, allowMetered)
|
||||
}
|
||||
|
||||
private fun hasUnmeteredInternet(context: Context): Boolean {
|
||||
private fun hasUnmeteredInternet(context: Context, allowMetered: Boolean): Boolean {
|
||||
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
|
||||
val isMetered = cm.isActiveNetworkMetered
|
||||
val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && !isMetered
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
(allowMetered || !isMetered)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,8 +11,8 @@ import android.net.Network
|
|||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.net.Uri
|
||||
import android.os.BadParcelableException
|
||||
import android.os.Process.myUid
|
||||
import android.os.UserHandle
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
|
@ -22,10 +22,14 @@ import androidx.core.content.ContextCompat.startForegroundService
|
|||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.liveData
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.switchMap
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.recyclerview.widget.DiffUtil.calculateDiff
|
||||
import com.stevesoltys.seedvault.BackupWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
|
@ -33,9 +37,12 @@ import com.stevesoltys.seedvault.permitDiskReads
|
|||
import com.stevesoltys.seedvault.storage.StorageBackupJobService
|
||||
import com.stevesoltys.seedvault.storage.StorageBackupService
|
||||
import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP
|
||||
import com.stevesoltys.seedvault.transport.requestBackup
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupInitializer
|
||||
import com.stevesoltys.seedvault.ui.LiveEvent
|
||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.worker.AppBackupWorker
|
||||
import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
@ -52,16 +59,17 @@ internal class SettingsViewModel(
|
|||
app: Application,
|
||||
settingsManager: SettingsManager,
|
||||
keyManager: KeyManager,
|
||||
private val notificationManager: BackupNotificationManager,
|
||||
private val metadataManager: MetadataManager,
|
||||
private val appListRetriever: AppListRetriever,
|
||||
private val storageBackup: StorageBackup,
|
||||
private val backupManager: IBackupManager,
|
||||
private val backupInitializer: BackupInitializer,
|
||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
|
||||
|
||||
private val contentResolver = app.contentResolver
|
||||
private val connectivityManager: ConnectivityManager? =
|
||||
app.getSystemService(ConnectivityManager::class.java)
|
||||
private val workManager = WorkManager.getInstance(app)
|
||||
|
||||
override val isRestoreOperation = false
|
||||
|
||||
|
@ -69,6 +77,10 @@ internal class SettingsViewModel(
|
|||
val backupPossible: LiveData<Boolean> = mBackupPossible
|
||||
|
||||
internal val lastBackupTime = metadataManager.lastBackupTime
|
||||
internal val appBackupWorkInfo =
|
||||
workManager.getWorkInfosForUniqueWorkLiveData(UNIQUE_WORK_NAME).map {
|
||||
it.getOrNull(0)
|
||||
}
|
||||
|
||||
private val mAppStatusList = lastBackupTime.switchMap {
|
||||
// updates app list when lastBackupTime changes
|
||||
|
@ -82,21 +94,24 @@ internal class SettingsViewModel(
|
|||
private val _filesSummary = MutableLiveData<String>()
|
||||
internal val filesSummary: LiveData<String> = _filesSummary
|
||||
|
||||
private val _initEvent = MutableLiveEvent<Boolean>()
|
||||
val initEvent: LiveEvent<Boolean> = _initEvent
|
||||
|
||||
private val storageObserver = object : ContentObserver(null) {
|
||||
override fun onChange(selfChange: Boolean, uris: MutableCollection<Uri>, flags: Int) {
|
||||
onStorageLocationChanged()
|
||||
onStoragePropertiesChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class NetworkObserver : ConnectivityManager.NetworkCallback() {
|
||||
var registered = false
|
||||
override fun onAvailable(network: Network) {
|
||||
onStorageLocationChanged()
|
||||
onStoragePropertiesChanged()
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
super.onLost(network)
|
||||
onStorageLocationChanged()
|
||||
onStoragePropertiesChanged()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -111,13 +126,39 @@ internal class SettingsViewModel(
|
|||
// ensures the lastBackupTime LiveData gets set
|
||||
metadataManager.getLastBackupTime()
|
||||
}
|
||||
onStorageLocationChanged()
|
||||
onStoragePropertiesChanged()
|
||||
loadFilesSummary()
|
||||
}
|
||||
|
||||
override fun onStorageLocationChanged() {
|
||||
val storage = settingsManager.getStorage() ?: return
|
||||
|
||||
Log.i(TAG, "onStorageLocationChanged (isUsb: ${storage.isUsb}")
|
||||
if (storage.isUsb) {
|
||||
// disable storage backup if new storage is on USB
|
||||
cancelAppBackup()
|
||||
cancelFilesBackup()
|
||||
} else {
|
||||
// enable it, just in case the previous storage was on USB,
|
||||
// also to update the network requirement of the new storage
|
||||
scheduleAppBackup(CANCEL_AND_REENQUEUE)
|
||||
scheduleFilesBackup()
|
||||
}
|
||||
onStoragePropertiesChanged()
|
||||
}
|
||||
|
||||
fun onWorkerStateChanged() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val canDo = settingsManager.canDoBackupNow() &&
|
||||
appBackupWorkInfo.value?.state != WorkInfo.State.RUNNING
|
||||
mBackupPossible.postValue(canDo)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onStoragePropertiesChanged() {
|
||||
val storage = settingsManager.getStorage() ?: return
|
||||
|
||||
Log.d(TAG, "onStoragePropertiesChanged")
|
||||
// register storage observer
|
||||
try {
|
||||
contentResolver.unregisterContentObserver(storageObserver)
|
||||
|
@ -139,19 +180,8 @@ internal class SettingsViewModel(
|
|||
connectivityManager?.registerNetworkCallback(request, networkCallback)
|
||||
networkCallback.registered = true
|
||||
}
|
||||
|
||||
if (settingsManager.isStorageBackupEnabled()) {
|
||||
// disable storage backup if new storage is on USB
|
||||
if (storage.isUsb) disableStorageBackup()
|
||||
// enable it, just in case the previous storage was on USB,
|
||||
// also to update the network requirement of the new storage
|
||||
else enableStorageBackup()
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val canDo = settingsManager.canDoBackupNow()
|
||||
mBackupPossible.postValue(canDo)
|
||||
}
|
||||
// update whether we can do backups right now or not
|
||||
onWorkerStateChanged()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
|
@ -163,25 +193,27 @@ internal class SettingsViewModel(
|
|||
}
|
||||
|
||||
internal fun backupNow() {
|
||||
// maybe replace the check below with one that checks if our transport service is running
|
||||
if (notificationManager.hasActiveBackupNotifications()) {
|
||||
Toast.makeText(app, R.string.notification_backup_already_running, LENGTH_LONG).show()
|
||||
} else if (!backupManager.isBackupEnabled) {
|
||||
Toast.makeText(app, R.string.notification_backup_disabled, LENGTH_LONG).show()
|
||||
} else viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (settingsManager.isStorageBackupEnabled()) {
|
||||
val i = Intent(app, StorageBackupService::class.java)
|
||||
// this starts an app backup afterwards
|
||||
i.putExtra(EXTRA_START_APP_BACKUP, true)
|
||||
startForegroundService(app, i)
|
||||
} else {
|
||||
requestBackup(app)
|
||||
val isUsb = settingsManager.getStorage()?.isUsb ?: false
|
||||
AppBackupWorker.scheduleNow(app, reschedule = !isUsb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAppStatusResult(): LiveData<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 diff = calculateDiff(AppStatusDiff(oldList, list))
|
||||
emit(AppStatusResult(list, diff))
|
||||
|
@ -205,6 +237,30 @@ internal class SettingsViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
fun onBackupEnabled(enabled: Boolean) {
|
||||
if (enabled) {
|
||||
if (metadataManager.requiresInit) {
|
||||
val onError: () -> Unit = {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
val res = R.string.storage_check_fragment_backup_error
|
||||
Toast.makeText(app, res, LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
backupInitializer.initialize(onError) {
|
||||
_initEvent.postEvent(false)
|
||||
scheduleAppBackup(CANCEL_AND_REENQUEUE)
|
||||
}
|
||||
_initEvent.postEvent(true)
|
||||
}
|
||||
}
|
||||
// enable call log backups for existing installs (added end of 2020)
|
||||
enableCallLogBackup()
|
||||
} else {
|
||||
cancelAppBackup()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the call log will be included in backups.
|
||||
*
|
||||
|
@ -223,20 +279,33 @@ internal class SettingsViewModel(
|
|||
return keyManager.hasMainKey()
|
||||
}
|
||||
|
||||
fun enableStorageBackup() {
|
||||
fun scheduleAppBackup(existingWorkPolicy: ExistingPeriodicWorkPolicy) {
|
||||
val storage = settingsManager.getStorage() ?: error("no storage available")
|
||||
if (!storage.isUsb) BackupJobService.scheduleJob(
|
||||
context = app,
|
||||
jobServiceClass = StorageBackupJobService::class.java,
|
||||
periodMillis = HOURS.toMillis(24),
|
||||
networkType = if (storage.requiresNetwork) NETWORK_TYPE_UNMETERED
|
||||
else NETWORK_TYPE_NONE,
|
||||
deviceIdle = false,
|
||||
charging = true
|
||||
)
|
||||
if (!storage.isUsb && backupManager.isBackupEnabled) {
|
||||
AppBackupWorker.schedule(app, settingsManager, existingWorkPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableStorageBackup() {
|
||||
fun scheduleFilesBackup() {
|
||||
val storage = settingsManager.getStorage() ?: error("no storage available")
|
||||
if (!storage.isUsb && settingsManager.isStorageBackupEnabled()) {
|
||||
BackupJobService.scheduleJob(
|
||||
context = app,
|
||||
jobServiceClass = StorageBackupJobService::class.java,
|
||||
periodMillis = HOURS.toMillis(24),
|
||||
networkType = if (storage.requiresNetwork) NETWORK_TYPE_UNMETERED
|
||||
else NETWORK_TYPE_NONE,
|
||||
deviceIdle = false,
|
||||
charging = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelAppBackup() {
|
||||
AppBackupWorker.unschedule(app)
|
||||
}
|
||||
|
||||
fun cancelFilesBackup() {
|
||||
BackupJobService.cancelJob(app)
|
||||
}
|
||||
|
||||
|
@ -264,13 +333,4 @@ internal class SettingsViewModel(
|
|||
Toast.makeText(app, str, LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
fun onD2dChanged(enabled: Boolean) {
|
||||
backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), !enabled)
|
||||
if (enabled) {
|
||||
BackupWorker.schedule(app)
|
||||
} else {
|
||||
BackupWorker.unschedule(app)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
package com.stevesoltys.seedvault.storage
|
||||
|
||||
import android.content.Intent
|
||||
import com.stevesoltys.seedvault.transport.requestBackup
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.worker.AppBackupWorker
|
||||
import org.calyxos.backup.storage.api.BackupObserver
|
||||
import org.calyxos.backup.storage.api.RestoreObserver
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
|
@ -23,6 +24,7 @@ force running with:
|
|||
adb shell cmd jobscheduler run -f com.stevesoltys.seedvault 0
|
||||
|
||||
*/
|
||||
|
||||
internal class StorageBackupJobService : BackupJobService(StorageBackupService::class.java)
|
||||
|
||||
internal class StorageBackupService : BackupService() {
|
||||
|
@ -32,6 +34,7 @@ internal class StorageBackupService : BackupService() {
|
|||
}
|
||||
|
||||
override val storageBackup: StorageBackup by inject()
|
||||
private val settingsManager: SettingsManager by inject()
|
||||
|
||||
// use lazy delegate because context isn't available during construction time
|
||||
override val backupObserver: BackupObserver by lazy {
|
||||
|
@ -40,7 +43,8 @@ internal class StorageBackupService : BackupService() {
|
|||
|
||||
override fun onBackupFinished(intent: Intent, success: Boolean) {
|
||||
if (intent.getBooleanExtra(EXTRA_START_APP_BACKUP, false)) {
|
||||
requestBackup(applicationContext)
|
||||
val isUsb = settingsManager.getStorage()?.isUsb ?: false
|
||||
AppBackupWorker.scheduleNow(applicationContext, reschedule = !isUsb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -130,8 +130,8 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
|
|||
return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup)
|
||||
}
|
||||
|
||||
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long = runBlocking {
|
||||
backupCoordinator.getBackupQuota(packageName, isFullBackup)
|
||||
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
|
||||
return backupCoordinator.getBackupQuota(packageName, isFullBackup)
|
||||
}
|
||||
|
||||
override fun clearBackupData(packageInfo: PackageInfo): Int = runBlocking {
|
||||
|
|
|
@ -2,18 +2,13 @@ package com.stevesoltys.seedvault.transport
|
|||
|
||||
import android.app.Service
|
||||
import android.app.backup.IBackupManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupRequester
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.core.context.GlobalContext.get
|
||||
|
||||
private val TAG = ConfigurableBackupTransportService::class.java.simpleName
|
||||
|
||||
|
@ -56,23 +51,3 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the system to initiate a backup.
|
||||
*
|
||||
* @return true iff backups was requested successfully (backup itself can still fail).
|
||||
*/
|
||||
@WorkerThread
|
||||
fun requestBackup(context: Context): Boolean {
|
||||
val backupManager: IBackupManager = get().get()
|
||||
return if (backupManager.isBackupEnabled) {
|
||||
val packageService: PackageService = get().get()
|
||||
|
||||
Log.d(TAG, "Backup is enabled, request backup...")
|
||||
val backupRequester = BackupRequester(context, backupManager, packageService)
|
||||
return backupRequester.requestBackup()
|
||||
} else {
|
||||
Log.i(TAG, "Backup is not enabled")
|
||||
true // this counts as success
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,21 +13,17 @@ import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
|
|||
import android.app.backup.RestoreSet
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.Clock
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.metadata.BackupType
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.metadata.PackageState
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
|
@ -65,7 +61,6 @@ internal class BackupCoordinator(
|
|||
private val plugin: StoragePlugin,
|
||||
private val kv: KVBackup,
|
||||
private val full: FullBackup,
|
||||
private val apkBackup: ApkBackup,
|
||||
private val clock: Clock,
|
||||
private val packageService: PackageService,
|
||||
private val metadataManager: MetadataManager,
|
||||
|
@ -91,12 +86,13 @@ internal class BackupCoordinator(
|
|||
* @return the token of the new [RestoreSet].
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
private suspend fun startNewRestoreSet(): Long {
|
||||
private suspend fun startNewRestoreSet() {
|
||||
val token = clock.time()
|
||||
Log.i(TAG, "Starting new RestoreSet with token $token...")
|
||||
settingsManager.setNewToken(token)
|
||||
plugin.startNewRestoreSet(token)
|
||||
return token
|
||||
Log.d(TAG, "Resetting backup metadata...")
|
||||
metadataManager.onDeviceInitialization(token)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -120,18 +116,14 @@ internal class BackupCoordinator(
|
|||
suspend fun initializeDevice(): Int = try {
|
||||
// we don't respect the intended system behavior here by always starting a new [RestoreSet]
|
||||
// instead of simply deleting the current one
|
||||
val token = startNewRestoreSet()
|
||||
startNewRestoreSet()
|
||||
Log.i(TAG, "Initialize Device!")
|
||||
plugin.initializeDevice()
|
||||
Log.d(TAG, "Resetting backup metadata for token $token...")
|
||||
plugin.getMetadataOutputStream(token).use {
|
||||
metadataManager.onDeviceInitialization(token, it)
|
||||
}
|
||||
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
|
||||
// so we remember that we initialized successfully
|
||||
state.calledInitialize = true
|
||||
TRANSPORT_OK
|
||||
} catch (e: IOException) {
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error initializing device", e)
|
||||
// Show error notification if we needed init or were ready for backups
|
||||
if (metadataManager.requiresInit || settingsManager.canDoBackupNow()) nm.onBackupError()
|
||||
|
@ -156,13 +148,7 @@ internal class BackupCoordinator(
|
|||
* otherwise for key-value backup.
|
||||
* @return Current limit on backup size in bytes.
|
||||
*/
|
||||
suspend fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
|
||||
if (packageName != MAGIC_PACKAGE_MANAGER) {
|
||||
// try to back up APK here as later methods are sometimes not called
|
||||
// TODO move this into BackupWorker
|
||||
backUpApk(context.packageManager.getPackageInfo(packageName, GET_SIGNING_CERTIFICATES))
|
||||
}
|
||||
|
||||
fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
|
||||
// report back quota
|
||||
Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.")
|
||||
val quota = if (isFullBackup) full.getQuota() else kv.getQuota()
|
||||
|
@ -233,14 +219,11 @@ internal class BackupCoordinator(
|
|||
state.cancelReason = UNKNOWN_ERROR
|
||||
if (metadataManager.requiresInit) {
|
||||
Log.w(TAG, "Metadata requires re-init!")
|
||||
// start a new restore set to upgrade from legacy format
|
||||
// by starting a clean backup with all files using the new version
|
||||
try {
|
||||
startNewRestoreSet()
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error starting new restore set", e)
|
||||
}
|
||||
// this causes a backup error, but things should go back to normal afterwards
|
||||
// Tell the system that we are not initialized, it will initialize us afterwards.
|
||||
// This will start a new restore set to upgrade from legacy format
|
||||
// by starting a clean backup with all files using the new version.
|
||||
//
|
||||
// This causes a backup error, but things should go back to normal afterwards.
|
||||
return TRANSPORT_NOT_INITIALIZED
|
||||
}
|
||||
val token = settingsManager.getToken() ?: error("no token in performFullBackup")
|
||||
|
@ -369,9 +352,9 @@ internal class BackupCoordinator(
|
|||
// tell K/V backup to finish
|
||||
var result = kv.finishBackup()
|
||||
if (result == TRANSPORT_OK) {
|
||||
val isPmBackup = packageName == MAGIC_PACKAGE_MANAGER
|
||||
val isNormalBackup = packageName != MAGIC_PACKAGE_MANAGER
|
||||
// call onPackageBackedUp for @pm@ only if we can do backups right now
|
||||
if (!isPmBackup || settingsManager.canDoBackupNow()) {
|
||||
if (isNormalBackup || settingsManager.canDoBackupNow()) {
|
||||
try {
|
||||
onPackageBackedUp(packageInfo, BackupType.KV, size)
|
||||
} catch (e: Exception) {
|
||||
|
@ -379,17 +362,6 @@ internal class BackupCoordinator(
|
|||
result = TRANSPORT_PACKAGE_REJECTED
|
||||
}
|
||||
}
|
||||
// hook in here to back up APKs of apps that are otherwise not allowed for backup
|
||||
// TODO move this into BackupWorker
|
||||
if (isPmBackup && settingsManager.canDoBackupNow()) {
|
||||
try {
|
||||
backUpApksOfNotBackedUpPackages()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error backing up APKs of opt-out apps: ", e)
|
||||
// We are re-throwing this, because we want to know about problems here
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
@ -418,65 +390,6 @@ internal class BackupCoordinator(
|
|||
else -> throw IllegalStateException("Unexpected state in finishBackup()")
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal suspend fun backUpApksOfNotBackedUpPackages() {
|
||||
Log.d(TAG, "Checking if APKs of opt-out apps need backup...")
|
||||
val notBackedUpPackages = packageService.notBackedUpPackages
|
||||
notBackedUpPackages.forEachIndexed { i, packageInfo ->
|
||||
val packageName = packageInfo.packageName
|
||||
try {
|
||||
nm.onOptOutAppBackup(packageName, i + 1, notBackedUpPackages.size)
|
||||
val packageState = if (packageInfo.isStopped()) WAS_STOPPED else NOT_ALLOWED
|
||||
val wasBackedUp = backUpApk(packageInfo, packageState)
|
||||
if (wasBackedUp) {
|
||||
Log.d(TAG, "Was backed up: $packageName")
|
||||
} else {
|
||||
Log.d(TAG, "Not backed up: $packageName - ${packageState.name}")
|
||||
val packageMetadata =
|
||||
metadataManager.getPackageMetadata(packageName)
|
||||
val oldPackageState = packageMetadata?.state
|
||||
if (oldPackageState != packageState) {
|
||||
Log.i(
|
||||
TAG, "Package $packageName was in $oldPackageState" +
|
||||
", update to $packageState"
|
||||
)
|
||||
plugin.getMetadataOutputStream().use {
|
||||
metadataManager.onPackageBackupError(packageInfo, packageState, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error backing up opt-out APK of $packageName", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backs up an APK for the given [PackageInfo].
|
||||
*
|
||||
* @return true if a backup was performed and false if no backup was needed or it failed.
|
||||
*/
|
||||
private suspend fun backUpApk(
|
||||
packageInfo: PackageInfo,
|
||||
packageState: PackageState = UNKNOWN_ERROR,
|
||||
): Boolean {
|
||||
val packageName = packageInfo.packageName
|
||||
return try {
|
||||
apkBackup.backupApkIfNecessary(packageInfo, packageState) { name ->
|
||||
val token = settingsManager.getToken() ?: throw IOException("no current token")
|
||||
plugin.getOutputStream(token, name)
|
||||
}?.let { packageMetadata ->
|
||||
plugin.getMetadataOutputStream().use {
|
||||
metadataManager.onApkBackedUp(packageInfo, packageMetadata, it)
|
||||
}
|
||||
true
|
||||
} ?: false
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error while writing APK or metadata for $packageName", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onPackageBackedUp(packageInfo: PackageInfo, type: BackupType, size: Long?) {
|
||||
plugin.getMetadataOutputStream().use {
|
||||
metadataManager.onPackageBackedUp(packageInfo, type, size, it)
|
||||
|
@ -503,7 +416,10 @@ internal class BackupCoordinator(
|
|||
// back off if storage is removable and not available right now
|
||||
storage.isUnavailableUsb(context) -> longBackoff
|
||||
// back off if storage is on network, but we have no access
|
||||
storage.isUnavailableNetwork(context) -> HOURS.toMillis(1)
|
||||
storage.isUnavailableNetwork(
|
||||
context = context,
|
||||
allowMetered = settingsManager.useMeteredNetwork,
|
||||
) -> HOURS.toMillis(1)
|
||||
// otherwise no back off
|
||||
else -> 0L
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -4,6 +4,7 @@ import org.koin.android.ext.koin.androidContext
|
|||
import org.koin.dsl.module
|
||||
|
||||
val backupModule = module {
|
||||
single { BackupInitializer(get()) }
|
||||
single { InputFactory() }
|
||||
single {
|
||||
PackageService(
|
||||
|
@ -13,14 +14,6 @@ val backupModule = module {
|
|||
plugin = get()
|
||||
)
|
||||
}
|
||||
single {
|
||||
ApkBackup(
|
||||
pm = androidContext().packageManager,
|
||||
crypto = get(),
|
||||
settingsManager = get(),
|
||||
metadataManager = get()
|
||||
)
|
||||
}
|
||||
single<KvDbManager> { KvDbManagerImpl(androidContext()) }
|
||||
single {
|
||||
KVBackup(
|
||||
|
@ -45,7 +38,6 @@ val backupModule = module {
|
|||
plugin = get(),
|
||||
kv = get(),
|
||||
full = get(),
|
||||
apkBackup = get(),
|
||||
clock = get(),
|
||||
packageService = get(),
|
||||
metadataManager = get(),
|
||||
|
|
|
@ -73,6 +73,22 @@ internal class PackageService(
|
|||
return packageArray
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of packages that is installed and that we need to re-install for restore,
|
||||
* such as user-installed packages or updated system apps.
|
||||
*/
|
||||
val allUserPackages: List<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,
|
||||
* because they are currently force-stopped for example.
|
||||
|
@ -90,9 +106,9 @@ internal class PackageService(
|
|||
}.sortedBy { packageInfo ->
|
||||
packageInfo.packageName
|
||||
}.also { notAllowed ->
|
||||
// log eligible packages
|
||||
// log packages that don't get backed up
|
||||
if (Log.isLoggable(TAG, INFO)) {
|
||||
Log.i(TAG, "${notAllowed.size} apps do not allow backup:")
|
||||
Log.i(TAG, "${notAllowed.size} apps do not get backed up:")
|
||||
logPackages(notAllowed.map { it.packageName })
|
||||
}
|
||||
}
|
||||
|
@ -124,22 +140,6 @@ internal class PackageService(
|
|||
}
|
||||
}
|
||||
|
||||
val expectedAppTotals: ExpectedAppTotals
|
||||
@WorkerThread
|
||||
get() {
|
||||
var appsTotal = 0
|
||||
var appsNotIncluded = 0
|
||||
packageManager.getInstalledPackages(GET_INSTRUMENTATION).forEach { packageInfo ->
|
||||
if (packageInfo.isUserVisible(context)) {
|
||||
appsTotal++
|
||||
if (packageInfo.doesNotGetBackedUp()) {
|
||||
appsNotIncluded++
|
||||
}
|
||||
}
|
||||
}
|
||||
return ExpectedAppTotals(appsTotal, appsNotIncluded)
|
||||
}
|
||||
|
||||
fun getVersionName(packageName: String): String? = try {
|
||||
packageManager.getPackageInfo(packageName, 0).versionName
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
|
@ -208,19 +208,6 @@ internal class PackageService(
|
|||
}
|
||||
}
|
||||
|
||||
internal data class ExpectedAppTotals(
|
||||
/**
|
||||
* The total number of non-system apps eligible for backup.
|
||||
*/
|
||||
val appsTotal: Int,
|
||||
/**
|
||||
* The number of non-system apps that do not get backed up.
|
||||
* These are included here, because we'll at least back up their APKs,
|
||||
* so at least the app itself does get restored.
|
||||
*/
|
||||
val appsNotGettingBackedUp: Int,
|
||||
)
|
||||
|
||||
internal fun PackageInfo.isUserVisible(context: Context): Boolean {
|
||||
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false
|
||||
return !isNotUpdatedSystemApp() && instrumentation == null && packageName != context.packageName
|
||||
|
|
|
@ -17,11 +17,13 @@ abstract class BackupActivity : AppCompatActivity() {
|
|||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
protected fun showFragment(f: Fragment, addToBackStack: Boolean = false) {
|
||||
val fragmentTransaction = supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment, f)
|
||||
if (addToBackStack) fragmentTransaction.addToBackStack(null)
|
||||
fragmentTransaction.commit()
|
||||
protected fun showFragment(f: Fragment, addToBackStack: Boolean = false, tag: String? = null) {
|
||||
supportFragmentManager.beginTransaction().apply {
|
||||
if (tag == null) replace(R.id.fragment, f)
|
||||
else replace(R.id.fragment, f, tag)
|
||||
if (addToBackStack) addToBackStack(null)
|
||||
commit()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.stevesoltys.seedvault.ui.notification
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.NotificationManager.IMPORTANCE_DEFAULT
|
||||
|
@ -26,14 +27,13 @@ import com.stevesoltys.seedvault.restore.EXTRA_PACKAGE_NAME
|
|||
import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL
|
||||
import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST
|
||||
import com.stevesoltys.seedvault.settings.SettingsActivity
|
||||
import com.stevesoltys.seedvault.transport.backup.ExpectedAppTotals
|
||||
import kotlin.math.min
|
||||
|
||||
private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver"
|
||||
private const val CHANNEL_ID_SUCCESS = "NotificationBackupSuccess"
|
||||
private const val CHANNEL_ID_ERROR = "NotificationError"
|
||||
private const val CHANNEL_ID_RESTORE_ERROR = "NotificationRestoreError"
|
||||
private const val NOTIFICATION_ID_OBSERVER = 1
|
||||
internal const val NOTIFICATION_ID_OBSERVER = 1
|
||||
private const val NOTIFICATION_ID_SUCCESS = 2
|
||||
private const val NOTIFICATION_ID_ERROR = 3
|
||||
private const val NOTIFICATION_ID_RESTORE_ERROR = 4
|
||||
|
@ -50,14 +50,6 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
createNotificationChannel(getErrorChannel())
|
||||
createNotificationChannel(getRestoreErrorChannel())
|
||||
}
|
||||
private var expectedApps: Int? = null
|
||||
private var expectedOptOutApps: Int? = null
|
||||
private var expectedAppTotals: ExpectedAppTotals? = null
|
||||
|
||||
/**
|
||||
* Used as a (temporary) hack to fix progress reporting when fake d2d is enabled.
|
||||
*/
|
||||
private var optOutAppsDone = false
|
||||
|
||||
private fun getObserverChannel(): NotificationChannel {
|
||||
val title = context.getString(R.string.notification_channel_title)
|
||||
|
@ -84,95 +76,70 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Call this right after starting a backup.
|
||||
* This should get called for each APK we are backing up.
|
||||
*/
|
||||
fun onBackupStarted(
|
||||
expectedPackages: Int,
|
||||
appTotals: ExpectedAppTotals,
|
||||
) {
|
||||
updateBackupNotification(
|
||||
infoText = "", // This passes quickly, no need to show something here
|
||||
transferred = 0,
|
||||
expected = appTotals.appsTotal
|
||||
)
|
||||
expectedApps = expectedPackages
|
||||
expectedOptOutApps = appTotals.appsNotGettingBackedUp
|
||||
expectedAppTotals = appTotals
|
||||
optOutAppsDone = false
|
||||
Log.i(TAG, "onBackupStarted $expectedApps + $expectedOptOutApps = ${appTotals.appsTotal}")
|
||||
fun onApkBackup(packageName: String, name: CharSequence, transferred: Int, expected: Int) {
|
||||
Log.i(TAG, "$transferred/$expected - $name ($packageName)")
|
||||
val text = context.getString(R.string.notification_apk_text, name)
|
||||
updateBackupNotification(text, transferred, expected)
|
||||
}
|
||||
|
||||
/**
|
||||
* This should get called before [onBackupUpdate].
|
||||
* In case of d2d backups, this actually gets called some time after
|
||||
* some apps were already backed up, so [onBackupUpdate] was called several times.
|
||||
* This should get called for recording apps we don't back up.
|
||||
*/
|
||||
fun onOptOutAppBackup(packageName: String, transferred: Int, expected: Int) {
|
||||
if (optOutAppsDone) return
|
||||
fun onAppsNotBackedUp() {
|
||||
Log.i(TAG, "onAppsNotBackedUp")
|
||||
val text = context.getString(R.string.notification_apk_not_backed_up)
|
||||
updateBackupNotification(text)
|
||||
}
|
||||
|
||||
val text = "APK for $packageName"
|
||||
if (expectedApps == null) {
|
||||
updateBackgroundBackupNotification(text)
|
||||
} else {
|
||||
updateBackupNotification(text, transferred, expected + (expectedApps ?: 0))
|
||||
if (expectedOptOutApps != null && expectedOptOutApps != expected) {
|
||||
Log.w(TAG, "Number of packages not getting backed up mismatch: " +
|
||||
"$expectedOptOutApps != $expected")
|
||||
}
|
||||
expectedOptOutApps = expected
|
||||
if (transferred == expected) optOutAppsDone = true
|
||||
}
|
||||
/**
|
||||
* Call after [onApkBackup] or [onAppsNotBackedUp] were called.
|
||||
*/
|
||||
fun onApkBackupDone() {
|
||||
nm.cancel(NOTIFICATION_ID_OBSERVER)
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this right after starting a backup.
|
||||
*/
|
||||
fun onBackupStarted(expectedPackages: Int) {
|
||||
updateBackupNotification(
|
||||
text = "", // This passes quickly, no need to show something here
|
||||
transferred = 0,
|
||||
expected = expectedPackages
|
||||
)
|
||||
Log.i(TAG, "onBackupStarted - Expecting $expectedPackages apps")
|
||||
}
|
||||
|
||||
/**
|
||||
* In the series of notification updates,
|
||||
* this type is is expected to get called after [onOptOutAppBackup].
|
||||
* this type is is expected to get called after [onApkBackup].
|
||||
*/
|
||||
fun onBackupUpdate(app: CharSequence, transferred: Int) {
|
||||
val expected = expectedApps ?: error("expectedApps is null")
|
||||
val addend = expectedOptOutApps ?: 0
|
||||
updateBackupNotification(
|
||||
infoText = app,
|
||||
transferred = min(transferred + addend, expected + addend),
|
||||
expected = expected + addend
|
||||
)
|
||||
fun onBackupUpdate(app: CharSequence, transferred: Int, total: Int) {
|
||||
updateBackupNotification(app, min(transferred, total), total)
|
||||
}
|
||||
|
||||
private fun updateBackupNotification(
|
||||
infoText: CharSequence,
|
||||
transferred: Int,
|
||||
expected: Int,
|
||||
text: CharSequence,
|
||||
transferred: Int = 0,
|
||||
expected: Int = 0,
|
||||
) {
|
||||
@Suppress("MagicNumber")
|
||||
val percentage = (transferred.toFloat() / expected) * 100
|
||||
val percentageStr = "%.0f%%".format(percentage)
|
||||
Log.i(TAG, "$transferred/$expected - $percentageStr - $infoText")
|
||||
val notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
|
||||
setSmallIcon(R.drawable.ic_cloud_upload)
|
||||
setContentTitle(context.getString(R.string.notification_title))
|
||||
setContentText(percentageStr)
|
||||
setOngoing(true)
|
||||
setShowWhen(false)
|
||||
setWhen(System.currentTimeMillis())
|
||||
setProgress(expected, transferred, false)
|
||||
priority = PRIORITY_DEFAULT
|
||||
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
|
||||
}.build()
|
||||
val notification = getBackupNotification(text, transferred, expected)
|
||||
nm.notify(NOTIFICATION_ID_OBSERVER, notification)
|
||||
}
|
||||
|
||||
private fun updateBackgroundBackupNotification(infoText: CharSequence) {
|
||||
Log.i(TAG, "$infoText")
|
||||
val notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
|
||||
fun getBackupNotification(text: CharSequence, progress: Int = 0, total: Int = 0): Notification {
|
||||
return Builder(context, CHANNEL_ID_OBSERVER).apply {
|
||||
setSmallIcon(R.drawable.ic_cloud_upload)
|
||||
setContentTitle(context.getString(R.string.notification_title))
|
||||
setContentText(text)
|
||||
setOngoing(true)
|
||||
setShowWhen(false)
|
||||
setWhen(System.currentTimeMillis())
|
||||
setProgress(0, 0, true)
|
||||
priority = PRIORITY_LOW
|
||||
setProgress(total, progress, progress == 0 && total == 0)
|
||||
priority = PRIORITY_DEFAULT
|
||||
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
|
||||
}.build()
|
||||
nm.notify(NOTIFICATION_ID_BACKGROUND, notification)
|
||||
}
|
||||
|
||||
fun onServiceDestroyed() {
|
||||
|
@ -197,11 +164,10 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
// }
|
||||
}
|
||||
|
||||
fun onBackupFinished(success: Boolean, numBackedUp: Int?, size: Long) {
|
||||
fun onBackupFinished(success: Boolean, numBackedUp: Int?, total: Int, size: Long) {
|
||||
val titleRes =
|
||||
if (success) R.string.notification_success_title else R.string.notification_failed_title
|
||||
val total = expectedAppTotals?.appsTotal
|
||||
val contentText = if (numBackedUp == null || total == null) null else {
|
||||
val contentText = if (numBackedUp == null) null else {
|
||||
val sizeStr = Formatter.formatShortFileSize(context, size)
|
||||
context.getString(R.string.notification_success_text, numBackedUp, total, sizeStr)
|
||||
}
|
||||
|
@ -224,20 +190,6 @@ internal class BackupNotificationManager(private val context: Context) {
|
|||
}.build()
|
||||
nm.cancel(NOTIFICATION_ID_OBSERVER)
|
||||
nm.notify(NOTIFICATION_ID_SUCCESS, notification)
|
||||
// reset number of expected apps
|
||||
expectedOptOutApps = null
|
||||
expectedApps = null
|
||||
expectedAppTotals = null
|
||||
}
|
||||
|
||||
fun hasActiveBackupNotifications(): Boolean {
|
||||
nm.activeNotifications.forEach {
|
||||
if (it.packageName == context.packageName) {
|
||||
if (it.id == NOTIFICATION_ID_BACKGROUND) return true
|
||||
if (it.id == NOTIFICATION_ID_OBSERVER) return it.isOngoing
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
|
|
|
@ -3,6 +3,7 @@ package com.stevesoltys.seedvault.ui.notification
|
|||
import android.app.backup.BackupProgress
|
||||
import android.app.backup.IBackupObserver
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
|
||||
import android.content.pm.PackageManager.NameNotFoundException
|
||||
import android.util.Log
|
||||
import android.util.Log.INFO
|
||||
|
@ -10,8 +11,8 @@ import android.util.Log.isLoggable
|
|||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupRequester
|
||||
import com.stevesoltys.seedvault.transport.backup.ExpectedAppTotals
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
import com.stevesoltys.seedvault.worker.BackupRequester
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
|
@ -21,18 +22,20 @@ internal class NotificationBackupObserver(
|
|||
private val context: Context,
|
||||
private val backupRequester: BackupRequester,
|
||||
private val requestedPackages: Int,
|
||||
appTotals: ExpectedAppTotals,
|
||||
) : IBackupObserver.Stub(), KoinComponent {
|
||||
|
||||
private val nm: BackupNotificationManager by inject()
|
||||
private val metadataManager: MetadataManager by inject()
|
||||
private val packageService: PackageService by inject()
|
||||
private var currentPackage: String? = null
|
||||
private var numPackages: Int = 0
|
||||
private var numPackagesToReport: Int = 0
|
||||
private var pmCounted: Boolean = false
|
||||
|
||||
init {
|
||||
// Inform the notification manager that a backup has started
|
||||
// and inform about the expected numbers, so it can compute a total.
|
||||
nm.onBackupStarted(requestedPackages, appTotals)
|
||||
// and inform about the expected numbers of apps.
|
||||
nm.onBackupStarted(requestedPackages)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -63,6 +66,26 @@ internal class NotificationBackupObserver(
|
|||
if (isLoggable(TAG, INFO)) {
|
||||
Log.i(TAG, "Completed. Target: $target, status: $status")
|
||||
}
|
||||
// prevent double counting of @pm@ which gets backed up with each requested chunk
|
||||
if (target == MAGIC_PACKAGE_MANAGER) {
|
||||
if (!pmCounted) {
|
||||
numPackages += 1
|
||||
pmCounted = true
|
||||
}
|
||||
} else {
|
||||
numPackages += 1
|
||||
}
|
||||
// count package if success and not a system app
|
||||
if (status == 0 && target != null && target != MAGIC_PACKAGE_MANAGER) try {
|
||||
val appInfo = context.packageManager.getApplicationInfo(target, 0)
|
||||
// exclude system apps from final count for now
|
||||
if (appInfo.flags and FLAG_SYSTEM == 0) {
|
||||
numPackagesToReport += 1
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// should only happen for MAGIC_PACKAGE_MANAGER, but better save than sorry
|
||||
Log.e(TAG, "Error getting ApplicationInfo: ", e)
|
||||
}
|
||||
// often [onResult] gets called right away without any [onUpdate] call
|
||||
showProgressNotification(target)
|
||||
}
|
||||
|
@ -80,9 +103,14 @@ internal class NotificationBackupObserver(
|
|||
Log.i(TAG, "Backup finished $numPackages/$requestedPackages. Status: $status")
|
||||
}
|
||||
val success = status == 0
|
||||
val numBackedUp = if (success) metadataManager.getPackagesNumBackedUp() else null
|
||||
val size = if (success) metadataManager.getPackagesBackupSize() else 0L
|
||||
nm.onBackupFinished(success, numBackedUp, size)
|
||||
val total = try {
|
||||
packageService.allUserPackages.size
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting number of all user packages: ", e)
|
||||
requestedPackages
|
||||
}
|
||||
nm.onBackupFinished(success, numPackagesToReport, total, size)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -95,13 +123,13 @@ internal class NotificationBackupObserver(
|
|||
)
|
||||
currentPackage = packageName
|
||||
val appName = getAppName(packageName)
|
||||
val app = if (appName != packageName) {
|
||||
"${getAppName(packageName)} ($packageName)"
|
||||
val name = if (appName != packageName) {
|
||||
appName
|
||||
} else {
|
||||
packageName
|
||||
context.getString(R.string.backup_section_system)
|
||||
}
|
||||
numPackages += 1
|
||||
nm.onBackupUpdate(app, numPackages)
|
||||
Log.i(TAG, "$numPackages/$requestedPackages - $appName ($packageName)")
|
||||
nm.onBackupUpdate(name, numPackages, requestedPackages)
|
||||
}
|
||||
|
||||
private fun getAppName(packageId: String): CharSequence = getAppName(context, packageId)
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package com.stevesoltys.seedvault.ui.recoverycode
|
||||
|
||||
import android.app.backup.IBackupManager
|
||||
import android.os.UserHandle
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import cash.z.ecc.android.bip39.Mnemonics
|
||||
|
@ -12,8 +11,7 @@ import cash.z.ecc.android.bip39.toSeed
|
|||
import com.stevesoltys.seedvault.App
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupInitializer
|
||||
import com.stevesoltys.seedvault.ui.LiveEvent
|
||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
|
@ -32,7 +30,7 @@ internal class RecoveryCodeViewModel(
|
|||
private val crypto: Crypto,
|
||||
private val keyManager: KeyManager,
|
||||
private val backupManager: IBackupManager,
|
||||
private val backupCoordinator: BackupCoordinator,
|
||||
private val backupInitializer: BackupInitializer,
|
||||
private val notificationManager: BackupNotificationManager,
|
||||
private val storageBackup: StorageBackup,
|
||||
) : AndroidViewModel(app) {
|
||||
|
@ -102,17 +100,16 @@ internal class RecoveryCodeViewModel(
|
|||
*/
|
||||
fun reinitializeBackupLocation() {
|
||||
Log.d(TAG, "Re-initializing backup location...")
|
||||
// TODO this code is almost identical to BackupStorageViewModel#onLocationSet(), unify?
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
// remove old storage snapshots and clear cache
|
||||
storageBackup.deleteAllSnapshots()
|
||||
storageBackup.clearCache()
|
||||
try {
|
||||
// initialize the new location
|
||||
if (backupManager.isBackupEnabled) backupManager.initializeTransportsForUser(
|
||||
UserHandle.myUserId(),
|
||||
arrayOf(TRANSPORT_ID),
|
||||
null
|
||||
)
|
||||
if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) {
|
||||
// no-op
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error starting new RestoreSet", e)
|
||||
}
|
||||
|
|
|
@ -1,30 +1,30 @@
|
|||
package com.stevesoltys.seedvault.ui.storage
|
||||
|
||||
import android.app.Application
|
||||
import android.app.backup.BackupProgress
|
||||
import android.app.backup.IBackupManager
|
||||
import android.app.backup.IBackupObserver
|
||||
import android.app.job.JobInfo
|
||||
import android.net.Uri
|
||||
import android.os.UserHandle
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
||||
import com.stevesoltys.seedvault.transport.requestBackup
|
||||
import com.stevesoltys.seedvault.storage.StorageBackupJobService
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupInitializer
|
||||
import com.stevesoltys.seedvault.worker.AppBackupWorker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
import org.calyxos.backup.storage.backup.BackupJobService
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val TAG = BackupStorageViewModel::class.java.simpleName
|
||||
|
||||
internal class BackupStorageViewModel(
|
||||
private val app: Application,
|
||||
private val backupManager: IBackupManager,
|
||||
private val backupCoordinator: BackupCoordinator,
|
||||
private val backupInitializer: BackupInitializer,
|
||||
private val storageBackup: StorageBackup,
|
||||
settingsManager: SettingsManager,
|
||||
) : StorageViewModel(app, settingsManager) {
|
||||
|
@ -33,19 +33,39 @@ internal class BackupStorageViewModel(
|
|||
|
||||
override fun onLocationSet(uri: Uri) {
|
||||
val isUsb = saveStorage(uri)
|
||||
if (isUsb) {
|
||||
// disable storage backup if new storage is on USB
|
||||
cancelBackupWorkers()
|
||||
} else {
|
||||
// enable it, just in case the previous storage was on USB,
|
||||
// also to update the network requirement of the new storage
|
||||
scheduleBackupWorkers()
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// remove old storage snapshots and clear cache
|
||||
// TODO is this needed? It also does create all 255 chunk folders which takes time
|
||||
// pass a flag to getCurrentBackupSnapshots() to not create missing folders?
|
||||
storageBackup.deleteAllSnapshots()
|
||||
storageBackup.clearCache()
|
||||
try {
|
||||
// initialize the new location (if backups are enabled)
|
||||
if (backupManager.isBackupEnabled) backupManager.initializeTransportsForUser(
|
||||
UserHandle.myUserId(),
|
||||
arrayOf(TRANSPORT_ID),
|
||||
// if storage is on USB and this is not SetupWizard, do a backup right away
|
||||
InitializationObserver(isUsb && !isSetupWizard)
|
||||
) else {
|
||||
InitializationObserver(false).backupFinished(0)
|
||||
if (backupManager.isBackupEnabled) {
|
||||
val onError = {
|
||||
Log.e(TAG, "Error starting new RestoreSet")
|
||||
onInitializationError()
|
||||
}
|
||||
backupInitializer.initialize(onError) {
|
||||
val requestBackup = isUsb && !isSetupWizard
|
||||
if (requestBackup) {
|
||||
Log.i(TAG, "Requesting a backup now, because we use USB storage")
|
||||
AppBackupWorker.scheduleNow(app, reschedule = false)
|
||||
}
|
||||
// notify the UI that the location has been set
|
||||
mLocationChecked.postEvent(LocationResult())
|
||||
}
|
||||
} else {
|
||||
// notify the UI that the location has been set
|
||||
mLocationChecked.postEvent(LocationResult())
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error starting new RestoreSet", e)
|
||||
|
@ -54,34 +74,29 @@ internal class BackupStorageViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private inner class InitializationObserver(val requestBackup: Boolean) :
|
||||
IBackupObserver.Stub() {
|
||||
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
|
||||
// noop
|
||||
}
|
||||
|
||||
override fun onResult(target: String, status: Int) {
|
||||
// noop
|
||||
}
|
||||
|
||||
override fun backupFinished(status: Int) {
|
||||
if (Log.isLoggable(TAG, Log.INFO)) {
|
||||
Log.i(TAG, "Initialization finished. Status: $status")
|
||||
}
|
||||
if (status == 0) {
|
||||
// notify the UI that the location has been set
|
||||
mLocationChecked.postEvent(LocationResult())
|
||||
if (requestBackup) {
|
||||
requestBackup(app)
|
||||
}
|
||||
} else {
|
||||
// notify the UI that the location was invalid
|
||||
onInitializationError()
|
||||
private fun scheduleBackupWorkers() {
|
||||
val storage = settingsManager.getStorage() ?: error("no storage available")
|
||||
if (!storage.isUsb) {
|
||||
if (backupManager.isBackupEnabled) {
|
||||
AppBackupWorker.schedule(app, settingsManager, CANCEL_AND_REENQUEUE)
|
||||
}
|
||||
if (settingsManager.isStorageBackupEnabled()) BackupJobService.scheduleJob(
|
||||
context = app,
|
||||
jobServiceClass = StorageBackupJobService::class.java,
|
||||
periodMillis = TimeUnit.HOURS.toMillis(24),
|
||||
networkType = if (storage.requiresNetwork) JobInfo.NETWORK_TYPE_UNMETERED
|
||||
else JobInfo.NETWORK_TYPE_NONE,
|
||||
deviceIdle = false,
|
||||
charging = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelBackupWorkers() {
|
||||
AppBackupWorker.unschedule(app)
|
||||
BackupJobService.cancelJob(app)
|
||||
}
|
||||
|
||||
private fun onInitializationError() {
|
||||
val errorMsg = app.getString(R.string.storage_check_fragment_backup_error)
|
||||
mLocationChecked.postEvent(LocationResult(errorMsg))
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
package com.stevesoltys.seedvault.transport.backup
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.worker
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.pm.PackageInfo
|
||||
|
@ -13,8 +18,9 @@ import com.stevesoltys.seedvault.encodeBase64
|
|||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.metadata.PackageState
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.backup.isNotUpdatedSystemApp
|
||||
import com.stevesoltys.seedvault.transport.backup.isTestOnly
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
|
@ -44,7 +50,6 @@ internal class ApkBackup(
|
|||
@SuppressLint("NewApi") // can be removed when minSdk is set to 30
|
||||
suspend fun backupApkIfNecessary(
|
||||
packageInfo: PackageInfo,
|
||||
packageState: PackageState,
|
||||
streamGetter: suspend (name: String) -> OutputStream,
|
||||
): PackageMetadata? {
|
||||
// do not back up @pm@
|
||||
|
@ -118,11 +123,10 @@ internal class ApkBackup(
|
|||
val splits =
|
||||
if (packageInfo.splitNames == null) null else backupSplitApks(packageInfo, streamGetter)
|
||||
|
||||
Log.d(TAG, "Backed up new APK of $packageName with version $version.")
|
||||
Log.d(TAG, "Backed up new APK of $packageName with version ${packageInfo.versionName}.")
|
||||
|
||||
// return updated metadata
|
||||
return PackageMetadata(
|
||||
state = packageState,
|
||||
return packageMetadata.copy(
|
||||
version = version,
|
||||
installer = pm.getInstallSourceInfo(packageName).installingPackageName,
|
||||
splits = splits,
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.transport.backup
|
||||
package com.stevesoltys.seedvault.worker
|
||||
|
||||
import android.app.backup.BackupManager
|
||||
import android.app.backup.IBackupManager
|
||||
|
@ -12,6 +12,7 @@ import android.os.RemoteException
|
|||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.BackupMonitor
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.ui.notification.NotificationBackupObserver
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
@ -34,12 +35,13 @@ internal class BackupRequester(
|
|||
val packageService: PackageService,
|
||||
) : KoinComponent {
|
||||
|
||||
val isBackupEnabled: Boolean get() = backupManager.isBackupEnabled
|
||||
|
||||
private val packages = packageService.eligiblePackages
|
||||
private val observer = NotificationBackupObserver(
|
||||
context = context,
|
||||
backupRequester = this,
|
||||
requestedPackages = packages.size,
|
||||
appTotals = packageService.expectedAppTotals,
|
||||
)
|
||||
private val monitor = BackupMonitor()
|
||||
|
||||
|
@ -100,7 +102,7 @@ internal class BackupRequester(
|
|||
(packageIndex + NUM_PACKAGES_PER_TRANSACTION).coerceAtMost(packages.size)
|
||||
val packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray()
|
||||
val numBackingUp = packageIndex + packageChunk.size
|
||||
Log.i(TAG, "Requesting backup for $numBackingUp/${packages.size} packages...")
|
||||
Log.i(TAG, "Requesting backup for $numBackingUp of ${packages.size} packages...")
|
||||
packageIndex += packageChunk.size
|
||||
return packageChunk
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
10
app/src/main/res/drawable/ic_access_time.xml
Normal file
10
app/src/main/res/drawable/ic_access_time.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_battery_charging_full.xml
Normal file
10
app/src/main/res/drawable/ic_battery_charging_full.xml
Normal 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>
|
10
app/src/main/res/drawable/ic_network_warning.xml
Normal file
10
app/src/main/res/drawable/ic_network_warning.xml
Normal 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>
|
20
app/src/main/res/values/arrays.xml
Normal file
20
app/src/main/res/values/arrays.xml
Normal 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>
|
|
@ -30,6 +30,11 @@
|
|||
<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_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_now">Backup now</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_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_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>
|
||||
|
@ -122,6 +136,8 @@
|
|||
<string name="notification_channel_title">Backup notification</string>
|
||||
<string name="notification_success_channel_title">Success notification</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_disabled">Backup not enabled</string>
|
||||
|
||||
|
|
|
@ -46,6 +46,13 @@
|
|||
app:summary="@string/settings_backup_apk_summary"
|
||||
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 android:title="@string/settings_category_storage">
|
||||
|
|
34
app/src/main/res/xml/settings_scheduling.xml
Normal file
34
app/src/main/res/xml/settings_scheduling.xml
Normal 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>
|
|
@ -19,6 +19,8 @@ import org.koin.dsl.module
|
|||
|
||||
class TestApp : App() {
|
||||
|
||||
override val isTest: Boolean = true
|
||||
|
||||
private val testCryptoModule = module {
|
||||
factory<CipherFactory> { CipherFactoryImpl(get()) }
|
||||
single<KeyManager> { KeyManagerTestImpl() }
|
||||
|
|
|
@ -28,10 +28,12 @@ import io.mockk.verify
|
|||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.junit.runner.RunWith
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.robolectric.annotation.Config
|
||||
|
@ -98,7 +100,7 @@ class MetadataManagerTest {
|
|||
expectReadFromCache()
|
||||
expectModifyMetadata(initialMetadata)
|
||||
|
||||
manager.onDeviceInitialization(token, storageOutputStream)
|
||||
manager.onDeviceInitialization(token)
|
||||
|
||||
assertEquals(token, manager.getBackupToken())
|
||||
assertEquals(0L, manager.getLastBackupTime())
|
||||
|
@ -121,7 +123,7 @@ class MetadataManagerTest {
|
|||
expectReadFromCache()
|
||||
expectModifyMetadata(initialMetadata)
|
||||
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream)
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata)
|
||||
|
||||
assertEquals(packageMetadata, manager.getPackageMetadata(packageName))
|
||||
|
||||
|
@ -144,7 +146,7 @@ class MetadataManagerTest {
|
|||
expectReadFromCache()
|
||||
expectModifyMetadata(initialMetadata)
|
||||
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream)
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata)
|
||||
|
||||
assertEquals(packageMetadata.copy(system = true), manager.getPackageMetadata(packageName))
|
||||
|
||||
|
@ -171,9 +173,9 @@ class MetadataManagerTest {
|
|||
)
|
||||
|
||||
expectReadFromCache()
|
||||
expectModifyMetadata(initialMetadata)
|
||||
expectWriteToCache(initialMetadata)
|
||||
|
||||
manager.onApkBackedUp(packageInfo, updatedPackageMetadata, storageOutputStream)
|
||||
manager.onApkBackedUp(packageInfo, updatedPackageMetadata)
|
||||
|
||||
assertEquals(updatedPackageMetadata, manager.getPackageMetadata(packageName))
|
||||
|
||||
|
@ -184,7 +186,7 @@ class MetadataManagerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `test onApkBackedUp() limits state changes`() {
|
||||
fun `test onApkBackedUp() does not change package state`() {
|
||||
var version = Random.nextLong(Long.MAX_VALUE)
|
||||
var packageMetadata = PackageMetadata(
|
||||
version = version,
|
||||
|
@ -193,12 +195,12 @@ class MetadataManagerTest {
|
|||
)
|
||||
|
||||
expectReadFromCache()
|
||||
expectModifyMetadata(initialMetadata)
|
||||
expectWriteToCache(initialMetadata)
|
||||
val oldState = UNKNOWN_ERROR
|
||||
|
||||
// state doesn't change for APK_AND_DATA
|
||||
packageMetadata = packageMetadata.copy(version = ++version, state = APK_AND_DATA)
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream)
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata)
|
||||
assertEquals(
|
||||
packageMetadata.copy(state = oldState),
|
||||
manager.getPackageMetadata(packageName)
|
||||
|
@ -206,7 +208,7 @@ class MetadataManagerTest {
|
|||
|
||||
// state doesn't change for QUOTA_EXCEEDED
|
||||
packageMetadata = packageMetadata.copy(version = ++version, state = QUOTA_EXCEEDED)
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream)
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata)
|
||||
assertEquals(
|
||||
packageMetadata.copy(state = oldState),
|
||||
manager.getPackageMetadata(packageName)
|
||||
|
@ -214,25 +216,25 @@ class MetadataManagerTest {
|
|||
|
||||
// state doesn't change for NO_DATA
|
||||
packageMetadata = packageMetadata.copy(version = ++version, state = NO_DATA)
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream)
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata)
|
||||
assertEquals(
|
||||
packageMetadata.copy(state = oldState),
|
||||
manager.getPackageMetadata(packageName)
|
||||
)
|
||||
|
||||
// state DOES change for NOT_ALLOWED
|
||||
// state doesn't change for NOT_ALLOWED
|
||||
packageMetadata = packageMetadata.copy(version = ++version, state = NOT_ALLOWED)
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream)
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata)
|
||||
assertEquals(
|
||||
packageMetadata.copy(state = NOT_ALLOWED),
|
||||
packageMetadata.copy(state = oldState),
|
||||
manager.getPackageMetadata(packageName)
|
||||
)
|
||||
|
||||
// state DOES change for WAS_STOPPED
|
||||
// state doesn't change for WAS_STOPPED
|
||||
packageMetadata = packageMetadata.copy(version = ++version, state = WAS_STOPPED)
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata, storageOutputStream)
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata)
|
||||
assertEquals(
|
||||
packageMetadata.copy(state = WAS_STOPPED),
|
||||
packageMetadata.copy(state = oldState),
|
||||
manager.getPackageMetadata(packageName)
|
||||
)
|
||||
|
||||
|
@ -242,6 +244,39 @@ class MetadataManagerTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test onApkBackedUp() throws while writing local cache`() {
|
||||
val packageMetadata = PackageMetadata(
|
||||
time = 0L,
|
||||
version = Random.nextLong(Long.MAX_VALUE),
|
||||
installer = getRandomString(),
|
||||
signatures = listOf("sig")
|
||||
)
|
||||
|
||||
expectReadFromCache()
|
||||
|
||||
assertNull(manager.getPackageMetadata(packageName))
|
||||
|
||||
every { metadataWriter.encode(initialMetadata) } returns encodedMetadata
|
||||
every {
|
||||
context.openFileOutput(
|
||||
METADATA_CACHE_FILE,
|
||||
MODE_PRIVATE
|
||||
)
|
||||
} throws FileNotFoundException()
|
||||
|
||||
assertThrows<IOException> {
|
||||
manager.onApkBackedUp(packageInfo, packageMetadata)
|
||||
}
|
||||
|
||||
// metadata change got reverted
|
||||
assertNull(manager.getPackageMetadata(packageName))
|
||||
|
||||
verify {
|
||||
cacheInputStream.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test onPackageBackedUp()`() {
|
||||
packageInfo.applicationInfo.flags = FLAG_SYSTEM
|
||||
|
@ -317,10 +352,7 @@ class MetadataManagerTest {
|
|||
}
|
||||
|
||||
assertEquals(0L, manager.getLastBackupTime()) // time was reverted
|
||||
assertEquals(
|
||||
initialMetadata.packageMetadataMap[packageName],
|
||||
manager.getPackageMetadata(packageName)
|
||||
)
|
||||
assertNull(manager.getPackageMetadata(packageName)) // no package metadata got added
|
||||
|
||||
verify { cacheInputStream.close() }
|
||||
}
|
||||
|
@ -358,6 +390,70 @@ class MetadataManagerTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test onPackageDoesNotGetBackedUp() updates state`() {
|
||||
val updatedMetadata = initialMetadata.copy()
|
||||
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = NOT_ALLOWED)
|
||||
|
||||
expectReadFromCache()
|
||||
expectWriteToCache(updatedMetadata)
|
||||
|
||||
manager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
|
||||
|
||||
assertEquals(
|
||||
updatedMetadata.packageMetadataMap[packageName],
|
||||
manager.getPackageMetadata(packageName),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test onPackageDoesNotGetBackedUp() creates new state`() {
|
||||
val updatedMetadata = initialMetadata.copy()
|
||||
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = WAS_STOPPED)
|
||||
initialMetadata.packageMetadataMap.remove(packageName)
|
||||
|
||||
expectReadFromCache()
|
||||
expectWriteToCache(updatedMetadata)
|
||||
|
||||
manager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED)
|
||||
|
||||
assertEquals(
|
||||
updatedMetadata.packageMetadataMap[packageName],
|
||||
manager.getPackageMetadata(packageName),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test onPackageBackupError() updates state`() {
|
||||
val updatedMetadata = initialMetadata.copy()
|
||||
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = NO_DATA)
|
||||
|
||||
expectReadFromCache()
|
||||
expectModifyMetadata(updatedMetadata)
|
||||
|
||||
manager.onPackageBackupError(packageInfo, NO_DATA, storageOutputStream, BackupType.KV)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test onPackageBackupError() inserts new package`() {
|
||||
val updatedMetadata = initialMetadata.copy()
|
||||
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(state = WAS_STOPPED)
|
||||
initialMetadata.packageMetadataMap.remove(packageName)
|
||||
|
||||
expectReadFromCache()
|
||||
expectModifyMetadata(updatedMetadata)
|
||||
|
||||
manager.onPackageBackupError(packageInfo, WAS_STOPPED, storageOutputStream)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test uploadMetadata() uploads`() {
|
||||
expectReadFromCache()
|
||||
every { metadataWriter.write(initialMetadata, storageOutputStream) } just Runs
|
||||
|
||||
manager.uploadMetadata(storageOutputStream)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test getBackupToken() on first run`() {
|
||||
every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException()
|
||||
|
@ -386,15 +482,7 @@ class MetadataManagerTest {
|
|||
|
||||
private fun expectModifyMetadata(metadata: BackupMetadata) {
|
||||
every { metadataWriter.write(metadata, storageOutputStream) } just Runs
|
||||
every { metadataWriter.encode(metadata) } returns encodedMetadata
|
||||
every {
|
||||
context.openFileOutput(
|
||||
METADATA_CACHE_FILE,
|
||||
MODE_PRIVATE
|
||||
)
|
||||
} returns cacheOutputStream
|
||||
every { cacheOutputStream.write(encodedMetadata) } just Runs
|
||||
every { cacheOutputStream.close() } just Runs
|
||||
expectWriteToCache(metadata)
|
||||
}
|
||||
|
||||
private fun expectReadFromCache() {
|
||||
|
@ -406,4 +494,16 @@ class MetadataManagerTest {
|
|||
every { cacheInputStream.close() } just Runs
|
||||
}
|
||||
|
||||
private fun expectWriteToCache(metadata: BackupMetadata) {
|
||||
every { metadataWriter.encode(metadata) } returns encodedMetadata
|
||||
every {
|
||||
context.openFileOutput(
|
||||
METADATA_CACHE_FILE,
|
||||
MODE_PRIVATE
|
||||
)
|
||||
} returns cacheOutputStream
|
||||
every { cacheOutputStream.write(encodedMetadata) } just Runs
|
||||
every { cacheOutputStream.close() } just Runs
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -10,12 +10,11 @@ import com.stevesoltys.seedvault.getRandomString
|
|||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||
import com.stevesoltys.seedvault.metadata.PackageState
|
||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.restore.RestorableBackup
|
||||
import com.stevesoltys.seedvault.transport.TransportTest
|
||||
import com.stevesoltys.seedvault.transport.backup.ApkBackup
|
||||
import com.stevesoltys.seedvault.worker.ApkBackup
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
@ -121,7 +120,7 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
|||
every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName
|
||||
every { storagePlugin.providerPackageName } returns storageProviderPackageName
|
||||
|
||||
apkBackup.backupApkIfNecessary(packageInfo, PackageState.APK_AND_DATA, outputStreamGetter)
|
||||
apkBackup.backupApkIfNecessary(packageInfo, outputStreamGetter)
|
||||
|
||||
assertArrayEquals(apkBytes, outputStream.toByteArray())
|
||||
assertArrayEquals(splitBytes, splitOutputStream.toByteArray())
|
||||
|
|
|
@ -15,11 +15,9 @@ import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH
|
|||
import com.stevesoltys.seedvault.metadata.BackupType
|
||||
import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
||||
import com.stevesoltys.seedvault.transport.backup.ApkBackup
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
||||
import com.stevesoltys.seedvault.transport.backup.FullBackup
|
||||
import com.stevesoltys.seedvault.transport.backup.InputFactory
|
||||
|
@ -31,6 +29,7 @@ import com.stevesoltys.seedvault.transport.restore.KVRestore
|
|||
import com.stevesoltys.seedvault.transport.restore.OutputFactory
|
||||
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.worker.ApkBackup
|
||||
import io.mockk.CapturingSlot
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
|
@ -73,7 +72,6 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
backupPlugin,
|
||||
kvBackup,
|
||||
fullBackup,
|
||||
apkBackup,
|
||||
clock,
|
||||
packageService,
|
||||
metadataManager,
|
||||
|
@ -138,13 +136,13 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
appData2.size
|
||||
}
|
||||
coEvery {
|
||||
apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any())
|
||||
apkBackup.backupApkIfNecessary(packageInfo, any())
|
||||
} returns packageMetadata
|
||||
coEvery {
|
||||
backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA)
|
||||
} returns metadataOutputStream
|
||||
every {
|
||||
metadataManager.onApkBackedUp(packageInfo, packageMetadata, metadataOutputStream)
|
||||
metadataManager.onApkBackedUp(packageInfo, packageMetadata)
|
||||
} just Runs
|
||||
every {
|
||||
metadataManager.onPackageBackedUp(
|
||||
|
@ -215,7 +213,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
appData.copyInto(value.captured) // write the app data into the passed ByteArray
|
||||
appData.size
|
||||
}
|
||||
coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null
|
||||
coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns null
|
||||
every { settingsManager.getToken() } returns token
|
||||
coEvery {
|
||||
backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA)
|
||||
|
@ -279,25 +277,13 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
coEvery { backupPlugin.getOutputStream(token, realName) } returns bOutputStream
|
||||
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
|
||||
every { settingsManager.isQuotaUnlimited() } returns false
|
||||
coEvery {
|
||||
apkBackup.backupApkIfNecessary(
|
||||
packageInfo,
|
||||
UNKNOWN_ERROR,
|
||||
any()
|
||||
)
|
||||
} returns packageMetadata
|
||||
coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns packageMetadata
|
||||
every { settingsManager.getToken() } returns token
|
||||
every { metadataManager.salt } returns salt
|
||||
coEvery {
|
||||
backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA)
|
||||
} returns metadataOutputStream
|
||||
every {
|
||||
metadataManager.onApkBackedUp(
|
||||
packageInfo,
|
||||
packageMetadata,
|
||||
metadataOutputStream
|
||||
)
|
||||
} just Runs
|
||||
every { metadataManager.onApkBackedUp(packageInfo, packageMetadata) } just Runs
|
||||
every {
|
||||
metadataManager.onPackageBackedUp(
|
||||
packageInfo = packageInfo,
|
||||
|
|
|
@ -5,7 +5,6 @@ import android.app.backup.BackupTransport.TRANSPORT_NOT_INITIALIZED
|
|||
import android.app.backup.BackupTransport.TRANSPORT_OK
|
||||
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
|
||||
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
|
||||
import android.content.pm.ApplicationInfo.FLAG_STOPPED
|
||||
import android.content.pm.PackageInfo
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
|
@ -14,18 +13,15 @@ import com.stevesoltys.seedvault.coAssertThrows
|
|||
import com.stevesoltys.seedvault.getRandomString
|
||||
import com.stevesoltys.seedvault.metadata.BackupType
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
||||
import com.stevesoltys.seedvault.settings.Storage
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.worker.ApkBackup
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
|
@ -36,7 +32,6 @@ import org.junit.jupiter.api.Test
|
|||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextLong
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class BackupCoordinatorTest : BackupTest() {
|
||||
|
@ -53,7 +48,6 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
plugin,
|
||||
kv,
|
||||
full,
|
||||
apkBackup,
|
||||
clock,
|
||||
packageService,
|
||||
metadataManager,
|
||||
|
@ -75,22 +69,18 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
fun `device initialization succeeds and delegates to plugin`() = runBlocking {
|
||||
expectStartNewRestoreSet()
|
||||
coEvery { plugin.initializeDevice() } just Runs
|
||||
coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
|
||||
every { metadataManager.onDeviceInitialization(token, metadataOutputStream) } just Runs
|
||||
every { kv.hasState() } returns false
|
||||
every { full.hasState() } returns false
|
||||
every { metadataOutputStream.close() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.initializeDevice())
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
|
||||
verify { metadataOutputStream.close() }
|
||||
}
|
||||
|
||||
private suspend fun expectStartNewRestoreSet() {
|
||||
every { clock.time() } returns token
|
||||
every { settingsManager.setNewToken(token) } just Runs
|
||||
coEvery { plugin.startNewRestoreSet(token) } just Runs
|
||||
every { metadataManager.onDeviceInitialization(token) } just Runs
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -142,6 +132,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
every { clock.time() } returns token + 1
|
||||
every { settingsManager.setNewToken(token + 1) } just Runs
|
||||
coEvery { plugin.startNewRestoreSet(token + 1) } just Runs
|
||||
every { metadataManager.onDeviceInitialization(token + 1) } just Runs
|
||||
|
||||
every { data.close() } just Runs
|
||||
|
||||
|
@ -157,16 +148,12 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
val isFullBackup = Random.nextBoolean()
|
||||
val quota = Random.nextLong()
|
||||
|
||||
expectApkBackupAndMetadataWrite()
|
||||
if (isFullBackup) {
|
||||
every { full.getQuota() } returns quota
|
||||
} else {
|
||||
every { kv.getQuota() } returns quota
|
||||
}
|
||||
every { metadataOutputStream.close() } just Runs
|
||||
assertEquals(quota, backup.getBackupQuota(packageInfo.packageName, isFullBackup))
|
||||
|
||||
verify { metadataOutputStream.close() }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -276,7 +263,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
coEvery {
|
||||
full.performFullBackup(packageInfo, fileDescriptor, 0, token, salt)
|
||||
} returns TRANSPORT_OK
|
||||
coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null
|
||||
coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns null
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0))
|
||||
}
|
||||
|
@ -304,6 +291,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
} just Runs
|
||||
coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs
|
||||
every { settingsManager.getStorage() } returns storage
|
||||
every { settingsManager.useMeteredNetwork } returns false
|
||||
every { metadataOutputStream.close() } just Runs
|
||||
|
||||
assertEquals(
|
||||
|
@ -353,6 +341,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
} just Runs
|
||||
coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs
|
||||
every { settingsManager.getStorage() } returns storage
|
||||
every { settingsManager.useMeteredNetwork } returns false
|
||||
every { metadataOutputStream.close() } just Runs
|
||||
|
||||
assertEquals(
|
||||
|
@ -380,180 +369,13 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
|
||||
@Test
|
||||
fun `not allowed apps get their APKs backed up after @pm@ backup`() = runBlocking {
|
||||
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
|
||||
val notAllowedPackages = listOf(
|
||||
PackageInfo().apply { packageName = "org.example.1" },
|
||||
PackageInfo().apply {
|
||||
packageName = "org.example.2"
|
||||
// the second package does not get backed up, because it is stopped
|
||||
applicationInfo = mockk {
|
||||
flags = FLAG_STOPPED
|
||||
}
|
||||
}
|
||||
)
|
||||
val packageMetadata: PackageMetadata = mockk()
|
||||
val size = Random.nextLong(1L..Long.MAX_VALUE)
|
||||
|
||||
every { settingsManager.canDoBackupNow() } returns true
|
||||
every { metadataManager.requiresInit } returns false
|
||||
every { settingsManager.getToken() } returns token
|
||||
every { metadataManager.salt } returns salt
|
||||
// do actual @pm@ backup
|
||||
coEvery {
|
||||
kv.performBackup(packageInfo, fileDescriptor, 0, token, salt)
|
||||
} returns TRANSPORT_OK
|
||||
|
||||
assertEquals(
|
||||
TRANSPORT_OK,
|
||||
backup.performIncrementalBackup(packageInfo, fileDescriptor, 0)
|
||||
)
|
||||
|
||||
// finish @pm@ backup
|
||||
every { kv.hasState() } returns true
|
||||
every { full.hasState() } returns false
|
||||
every { kv.getCurrentPackage() } returns pmPackageInfo
|
||||
every { kv.getCurrentSize() } returns size
|
||||
every {
|
||||
metadataManager.onPackageBackedUp(
|
||||
pmPackageInfo,
|
||||
BackupType.KV,
|
||||
size,
|
||||
metadataOutputStream,
|
||||
)
|
||||
} just Runs
|
||||
coEvery { kv.finishBackup() } returns TRANSPORT_OK
|
||||
|
||||
// now check if we have opt-out apps that we need to back up APKs for
|
||||
every { packageService.notBackedUpPackages } returns notAllowedPackages
|
||||
// update notification
|
||||
every {
|
||||
notificationManager.onOptOutAppBackup(
|
||||
notAllowedPackages[0].packageName,
|
||||
1,
|
||||
notAllowedPackages.size
|
||||
)
|
||||
} just Runs
|
||||
// no backup needed
|
||||
coEvery {
|
||||
apkBackup.backupApkIfNecessary(notAllowedPackages[0], NOT_ALLOWED, any())
|
||||
} returns null
|
||||
// check old metadata for state changes, because we won't update it otherwise
|
||||
every {
|
||||
metadataManager.getPackageMetadata(notAllowedPackages[0].packageName)
|
||||
} returns packageMetadata
|
||||
every { packageMetadata.state } returns NOT_ALLOWED // no change
|
||||
|
||||
// update notification for second package
|
||||
every {
|
||||
notificationManager.onOptOutAppBackup(
|
||||
notAllowedPackages[1].packageName,
|
||||
2,
|
||||
notAllowedPackages.size
|
||||
)
|
||||
} just Runs
|
||||
// was backed up, get new packageMetadata
|
||||
coEvery {
|
||||
apkBackup.backupApkIfNecessary(notAllowedPackages[1], WAS_STOPPED, any())
|
||||
} returns packageMetadata
|
||||
every { settingsManager.getToken() } returns token
|
||||
coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
|
||||
every {
|
||||
metadataManager.onApkBackedUp(
|
||||
notAllowedPackages[1],
|
||||
packageMetadata,
|
||||
metadataOutputStream
|
||||
)
|
||||
} just Runs
|
||||
every { metadataOutputStream.close() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
|
||||
coVerify {
|
||||
apkBackup.backupApkIfNecessary(notAllowedPackages[0], NOT_ALLOWED, any())
|
||||
apkBackup.backupApkIfNecessary(notAllowedPackages[1], WAS_STOPPED, any())
|
||||
metadataOutputStream.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `APK backup of not allowed apps updates state even without new APK`() = runBlocking {
|
||||
val oldPackageMetadata: PackageMetadata = mockk()
|
||||
|
||||
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
||||
every {
|
||||
notificationManager.onOptOutAppBackup(packageInfo.packageName, 1, 1)
|
||||
} just Runs
|
||||
coEvery { apkBackup.backupApkIfNecessary(packageInfo, NOT_ALLOWED, any()) } returns null
|
||||
every {
|
||||
metadataManager.getPackageMetadata(packageInfo.packageName)
|
||||
} returns oldPackageMetadata
|
||||
// state differs now, was stopped before
|
||||
every { oldPackageMetadata.state } returns WAS_STOPPED
|
||||
every { settingsManager.getToken() } returns token
|
||||
coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
|
||||
every {
|
||||
metadataManager.onPackageBackupError(
|
||||
packageInfo,
|
||||
NOT_ALLOWED,
|
||||
metadataOutputStream
|
||||
)
|
||||
} just Runs
|
||||
every { metadataOutputStream.close() } just Runs
|
||||
|
||||
backup.backUpApksOfNotBackedUpPackages()
|
||||
|
||||
verify {
|
||||
metadataManager.onPackageBackupError(packageInfo, NOT_ALLOWED, metadataOutputStream)
|
||||
metadataOutputStream.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `APK backup of not allowed apps updates state even without old state`() = runBlocking {
|
||||
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
||||
every {
|
||||
notificationManager.onOptOutAppBackup(packageInfo.packageName, 1, 1)
|
||||
} just Runs
|
||||
coEvery { apkBackup.backupApkIfNecessary(packageInfo, NOT_ALLOWED, any()) } returns null
|
||||
every {
|
||||
metadataManager.getPackageMetadata(packageInfo.packageName)
|
||||
} returns null
|
||||
every { settingsManager.getToken() } returns token
|
||||
coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
|
||||
every {
|
||||
metadataManager.onPackageBackupError(
|
||||
packageInfo,
|
||||
NOT_ALLOWED,
|
||||
metadataOutputStream
|
||||
)
|
||||
} just Runs
|
||||
every { metadataOutputStream.close() } just Runs
|
||||
|
||||
backup.backUpApksOfNotBackedUpPackages()
|
||||
|
||||
verify {
|
||||
metadataManager.onPackageBackupError(packageInfo, NOT_ALLOWED, metadataOutputStream)
|
||||
metadataOutputStream.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun expectApkBackupAndMetadataWrite() {
|
||||
coEvery {
|
||||
apkBackup.backupApkIfNecessary(
|
||||
any(),
|
||||
UNKNOWN_ERROR,
|
||||
any()
|
||||
)
|
||||
} returns packageMetadata
|
||||
coEvery { apkBackup.backupApkIfNecessary(any(), any()) } returns packageMetadata
|
||||
every { settingsManager.getToken() } returns token
|
||||
coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
|
||||
every {
|
||||
metadataManager.onApkBackedUp(
|
||||
any(),
|
||||
packageMetadata,
|
||||
metadataOutputStream
|
||||
)
|
||||
} just Runs
|
||||
every { metadataManager.onApkBackedUp(any(), packageMetadata) } just Runs
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -1,4 +1,9 @@
|
|||
package com.stevesoltys.seedvault.transport.backup
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.worker
|
||||
|
||||
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
|
||||
import android.content.pm.ApplicationInfo.FLAG_TEST_ONLY
|
||||
|
@ -13,6 +18,7 @@ import com.stevesoltys.seedvault.getRandomString
|
|||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupTest
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
@ -56,7 +62,7 @@ internal class ApkBackupTest : BackupTest() {
|
|||
@Test
|
||||
fun `does not back up @pm@`() = runBlocking {
|
||||
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -64,7 +70,7 @@ internal class ApkBackupTest : BackupTest() {
|
|||
every { settingsManager.backupApks() } returns false
|
||||
every { settingsManager.isBackupEnabled(any()) } returns true
|
||||
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -72,7 +78,7 @@ internal class ApkBackupTest : BackupTest() {
|
|||
every { settingsManager.backupApks() } returns true
|
||||
every { settingsManager.isBackupEnabled(any()) } returns false
|
||||
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -81,7 +87,7 @@ internal class ApkBackupTest : BackupTest() {
|
|||
|
||||
every { settingsManager.isBackupEnabled(any()) } returns true
|
||||
every { settingsManager.backupApks() } returns true
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -90,7 +96,7 @@ internal class ApkBackupTest : BackupTest() {
|
|||
|
||||
every { settingsManager.isBackupEnabled(any()) } returns true
|
||||
every { settingsManager.backupApks() } returns true
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -102,7 +108,7 @@ internal class ApkBackupTest : BackupTest() {
|
|||
|
||||
expectChecks(packageMetadata)
|
||||
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -113,7 +119,7 @@ internal class ApkBackupTest : BackupTest() {
|
|||
|
||||
assertThrows(IOException::class.java) {
|
||||
runBlocking {
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -128,7 +134,7 @@ internal class ApkBackupTest : BackupTest() {
|
|||
every { sigInfo.hasMultipleSigners() } returns false
|
||||
every { sigInfo.signingCertificateHistory } returns emptyArray()
|
||||
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
|
||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -141,7 +147,7 @@ internal class ApkBackupTest : BackupTest() {
|
|||
}.absolutePath
|
||||
val apkOutputStream = ByteArrayOutputStream()
|
||||
val updatedMetadata = PackageMetadata(
|
||||
time = 0L,
|
||||
time = packageMetadata.time,
|
||||
state = UNKNOWN_ERROR,
|
||||
version = packageInfo.longVersionCode,
|
||||
installer = getRandomString(),
|
||||
|
@ -159,7 +165,7 @@ internal class ApkBackupTest : BackupTest() {
|
|||
|
||||
assertEquals(
|
||||
updatedMetadata,
|
||||
apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)
|
||||
apkBackup.backupApkIfNecessary(packageInfo, streamGetter)
|
||||
)
|
||||
assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
|
||||
}
|
||||
|
@ -198,7 +204,7 @@ internal class ApkBackupTest : BackupTest() {
|
|||
val split2OutputStream = ByteArrayOutputStream()
|
||||
// expected new metadata for package
|
||||
val updatedMetadata = PackageMetadata(
|
||||
time = 0L,
|
||||
time = packageMetadata.time,
|
||||
state = UNKNOWN_ERROR,
|
||||
version = packageInfo.longVersionCode,
|
||||
installer = getRandomString(),
|
||||
|
@ -231,7 +237,7 @@ internal class ApkBackupTest : BackupTest() {
|
|||
|
||||
assertEquals(
|
||||
updatedMetadata,
|
||||
apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter)
|
||||
apkBackup.backupApkIfNecessary(packageInfo, streamGetter)
|
||||
)
|
||||
assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
|
||||
assertArrayEquals(split1Bytes, split1OutputStream.toByteArray())
|
Loading…
Add table
Reference in a new issue