Merge pull request #628 from grote/583-scheduling

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

View file

@ -18,7 +18,7 @@ jobs:
strategy:
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 }}

View file

@ -97,4 +97,7 @@ $ADB shell mkdir -p /sdcard/seedvault_baseline
$ADB shell tar xzf /sdcard/backup.tar.gz --directory=/sdcard/seedvault_baseline
$ADB shell 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!"

View file

@ -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." }

View file

@ -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"

View file

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

View file

@ -1,57 +0,0 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.stevesoltys.seedvault.transport.requestBackup
import java.util.concurrent.TimeUnit
class BackupWorker(
appContext: Context,
workerParams: WorkerParameters,
) : Worker(appContext, workerParams) {
companion object {
private const val UNIQUE_WORK_NAME = "APP_BACKUP"
fun schedule(appContext: Context) {
val backupConstraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresCharging(true)
.build()
val backupWorkRequest = PeriodicWorkRequestBuilder<BackupWorker>(
repeatInterval = 24,
repeatIntervalTimeUnit = TimeUnit.HOURS,
flexTimeInterval = 2,
flexTimeIntervalUnit = TimeUnit.HOURS,
).setConstraints(backupConstraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.HOURS)
.build()
val workManager = WorkManager.getInstance(appContext)
workManager.enqueueUniquePeriodicWork(UNIQUE_WORK_NAME, UPDATE, backupWorkRequest)
}
fun unschedule(appContext: Context) {
val workManager = WorkManager.getInstance(appContext)
workManager.cancelUniqueWork(UNIQUE_WORK_NAME)
}
}
override fun doWork(): Result {
// TODO once we make this the default, we should do storage backup here as well
// or have two workers and ensure they never run at the same time
return if (requestBackup(applicationContext)) Result.success()
else Result.retry()
}
}

View file

@ -20,15 +20,13 @@ import com.stevesoltys.seedvault.settings.FlashDrive
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.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)
}
}

View file

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

View file

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

View file

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

View file

@ -15,9 +15,9 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_A
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
import com.stevesoltys.seedvault.restore.install.ApkInstallState.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))

View file

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

View file

@ -0,0 +1,64 @@
package com.stevesoltys.seedvault.settings
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.permitDiskReads
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class SchedulingFragment : PreferenceFragmentCompat(),
SharedPreferences.OnSharedPreferenceChangeListener {
private val viewModel: SettingsViewModel by sharedViewModel()
private val settingsManager: SettingsManager by inject()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
permitDiskReads {
setPreferencesFromResource(R.xml.settings_scheduling, rootKey)
PreferenceManager.setDefaultValues(requireContext(), R.xml.settings_scheduling, false)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val storage = settingsManager.getStorage()
if (storage?.isUsb == true) {
findPreference<PreferenceCategory>("scheduling_category_conditions")?.isEnabled = false
}
}
override fun onStart() {
super.onStart()
activity?.setTitle(R.string.settings_backup_scheduling_title)
}
override fun onResume() {
super.onResume()
settingsManager.registerOnSharedPreferenceChangeListener(this)
}
override fun onPause() {
super.onPause()
settingsManager.unregisterOnSharedPreferenceChangeListener(this)
}
// we can not use setOnPreferenceChangeListener() because that gets called
// before prefs were saved
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
when (key) {
PREF_KEY_SCHED_FREQ -> viewModel.scheduleAppBackup(CANCEL_AND_REENQUEUE)
PREF_KEY_SCHED_METERED -> viewModel.scheduleAppBackup(UPDATE)
PREF_KEY_SCHED_CHARGING -> viewModel.scheduleAppBackup(UPDATE)
}
}
}

View file

@ -10,6 +10,7 @@ import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import com.stevesoltys.seedvault.ui.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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {

View file

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

View file

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

View file

@ -0,0 +1,73 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.BackupProgress
import android.app.backup.IBackupManager
import android.app.backup.IBackupObserver
import android.os.UserHandle
import android.util.Log
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
class BackupInitializer(
private val backupManager: IBackupManager,
) {
companion object {
private val TAG = BackupInitializer::class.simpleName
}
fun initialize(onError: () -> Unit, onSuccess: () -> Unit) {
val observer = BackupObserver("Initialization", onError) {
// After successful initialization, we request a @pm@ backup right away,
// because if this finds empty state, it asks us to do another initialization.
// And then we end up with yet another restore set token.
// Since we want the final token as soon as possible, we need to get it here.
Log.d(TAG, "Requesting initial $MAGIC_PACKAGE_MANAGER backup...")
backupManager.requestBackup(
arrayOf(MAGIC_PACKAGE_MANAGER),
BackupObserver("Initial backup of @pm@", onError, onSuccess),
BackupMonitor(),
0,
)
}
backupManager.initializeTransportsForUser(
UserHandle.myUserId(),
arrayOf(TRANSPORT_ID),
observer,
)
}
@WorkerThread
private inner class BackupObserver(
private val operation: String,
private val onError: () -> Unit,
private val onSuccess: () -> Unit,
) : IBackupObserver.Stub() {
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
// noop
}
override fun onResult(target: String, status: Int) {
// noop
}
override fun backupFinished(status: Int) {
if (Log.isLoggable(TAG, Log.INFO)) {
Log.i(TAG, "$operation finished. Status: $status")
}
if (status == 0) {
onSuccess()
} else {
onError()
}
}
}
}

View file

@ -4,6 +4,7 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
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(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,9 @@
package com.stevesoltys.seedvault.transport.backup
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.worker
import android.annotation.SuppressLint
import android.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,

View file

@ -0,0 +1,132 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.worker
import android.content.Context
import android.content.pm.PackageInfo
import android.util.Log
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.transport.backup.isStopped
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.notification.getAppName
import kotlinx.coroutines.delay
import java.io.IOException
import java.io.OutputStream
internal class ApkBackupManager(
private val context: Context,
private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager,
private val packageService: PackageService,
private val apkBackup: ApkBackup,
private val plugin: StoragePlugin,
private val nm: BackupNotificationManager,
) {
companion object {
private val TAG = ApkBackupManager::class.simpleName
}
suspend fun backup() {
try {
// We may be backing up APKs of packages that don't get their data backed up.
// Since an APK backup does not change the [packageState], we first record it for all
// packages that don't get backed up.
recordNotBackedUpPackages()
// Now, if APK backups are enabled by the user, we back those up.
if (settingsManager.backupApks()) {
backUpApks()
}
} finally {
keepTrying {
// upload all local changes only at the end,
// so we don't have to re-upload the metadata
plugin.getMetadataOutputStream().use { outputStream ->
metadataManager.uploadMetadata(outputStream)
}
}
nm.onApkBackupDone()
}
}
/**
* Goes through the list of all apps and uploads their APK, if needed.
*/
private suspend fun backUpApks() {
val apps = packageService.allUserPackages
apps.forEachIndexed { i, packageInfo ->
val packageName = packageInfo.packageName
val name = getAppName(context, packageName)
nm.onApkBackup(packageName, name, i, apps.size)
backUpApk(packageInfo)
}
}
private fun recordNotBackedUpPackages() {
nm.onAppsNotBackedUp()
packageService.notBackedUpPackages.forEach { packageInfo ->
val packageName = packageInfo.packageName
try {
val packageState = if (packageInfo.isStopped()) WAS_STOPPED else NOT_ALLOWED
val packageMetadata = metadataManager.getPackageMetadata(packageName)
val oldPackageState = packageMetadata?.state
if (oldPackageState != packageState) {
Log.i(
TAG, "Package $packageName was in $oldPackageState" +
", update to $packageState"
)
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, packageState)
}
} catch (e: IOException) {
Log.e(TAG, "Error storing new metadata for $packageName: ", e)
}
}
}
/**
* Backs up an APK for the given [PackageInfo].
*
* @return true if a backup was performed and false if no backup was needed or it failed.
*/
private suspend fun backUpApk(packageInfo: PackageInfo): Boolean {
val packageName = packageInfo.packageName
return try {
apkBackup.backupApkIfNecessary(packageInfo) { name ->
val token = settingsManager.getToken() ?: throw IOException("no current token")
plugin.getOutputStream(token, name)
}?.let { packageMetadata ->
metadataManager.onApkBackedUp(packageInfo, packageMetadata)
true
} ?: false
} catch (e: IOException) {
Log.e(TAG, "Error while writing APK for $packageName", e)
false
}
}
private suspend fun keepTrying(n: Int = 3, block: suspend () -> Unit) {
for (i in 1..n) {
try {
block()
} catch (e: Exception) {
if (i == n) throw e
Log.e(TAG, "Error (#$i), we'll keep trying", e)
delay(1000)
}
}
}
private suspend fun StoragePlugin.getMetadataOutputStream(token: Long? = null): OutputStream {
val t = token ?: settingsManager.getToken() ?: throw IOException("no current token")
return getOutputStream(t, FILE_BACKUP_METADATA)
}
}

View file

@ -0,0 +1,152 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.worker
import android.content.Context
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.text.format.DateUtils.formatElapsedTime
import android.util.Log
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
import androidx.work.ExistingWorkPolicy.REPLACE
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.notification.NOTIFICATION_ID_OBSERVER
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.util.concurrent.TimeUnit
class AppBackupWorker(
appContext: Context,
workerParams: WorkerParameters,
) : CoroutineWorker(appContext, workerParams), KoinComponent {
companion object {
private val TAG = AppBackupWorker::class.simpleName
internal const val UNIQUE_WORK_NAME = "com.stevesoltys.seedvault.APP_BACKUP"
private const val TAG_RESCHEDULE = "com.stevesoltys.seedvault.TAG_RESCHEDULE"
/**
* (Re-)schedules the [AppBackupWorker].
*
* @param existingWorkPolicy usually you want to use [ExistingPeriodicWorkPolicy.UPDATE]
* only if you are sure that work is still scheduled
* and you don't want to mess with the scheduling time.
* In most other cases, you want to use [ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE],
* because it ensures work gets schedules, even if it wasn't scheduled before.
* It will however reset the scheduling time.
*/
fun schedule(
context: Context,
settingsManager: SettingsManager,
existingWorkPolicy: ExistingPeriodicWorkPolicy,
) {
val logFrequency = formatElapsedTime(settingsManager.backupFrequencyInMillis / 1000)
Log.i(TAG, "Scheduling in $logFrequency...")
val constraints = Constraints.Builder().apply {
if (!settingsManager.useMeteredNetwork) {
Log.i(TAG, " only on unmetered networks")
setRequiredNetworkType(NetworkType.UNMETERED)
}
if (settingsManager.backupOnlyWhenCharging) {
Log.i(TAG, " only when the device is charging")
setRequiresCharging(true)
}
}.build()
val workRequest = PeriodicWorkRequestBuilder<AppBackupWorker>(
repeatInterval = settingsManager.backupFrequencyInMillis,
repeatIntervalTimeUnit = TimeUnit.MILLISECONDS,
flexTimeInterval = 2,
flexTimeIntervalUnit = TimeUnit.HOURS,
).setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.MINUTES)
.build()
val workManager = WorkManager.getInstance(context)
Log.i(TAG, " workRequest: ${workRequest.id}")
workManager.enqueueUniquePeriodicWork(UNIQUE_WORK_NAME, existingWorkPolicy, workRequest)
}
fun scheduleNow(context: Context, reschedule: Boolean) {
val workRequest = OneTimeWorkRequestBuilder<AppBackupWorker>()
.setExpedited(RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.apply { if (reschedule) addTag(TAG_RESCHEDULE) }
.build()
val workManager = WorkManager.getInstance(context)
Log.i(TAG, "Asking to do app backup now...")
workManager.enqueueUniqueWork(UNIQUE_WORK_NAME, REPLACE, workRequest)
}
fun unschedule(context: Context) {
Log.i(TAG, "Unscheduling app backup...")
val workManager = WorkManager.getInstance(context)
workManager.cancelUniqueWork(UNIQUE_WORK_NAME)
}
}
private val backupRequester: BackupRequester by inject()
private val settingsManager: SettingsManager by inject()
private val apkBackupManager: ApkBackupManager by inject()
private val nm: BackupNotificationManager by inject()
override suspend fun doWork(): Result {
Log.i(TAG, "Start worker $this ($id)")
try {
setForeground(createForegroundInfo())
} catch (e: Exception) {
Log.e(TAG, "Error while running setForeground: ", e)
}
return try {
if (isStopped) {
Result.retry()
} else {
doBackup()
}
} finally {
// schedule next backup, because the old one gets lost
// when scheduling a OneTimeWorkRequest with the same unique name via scheduleNow()
if (tags.contains(TAG_RESCHEDULE) && backupRequester.isBackupEnabled) {
// needs to use CANCEL_AND_REENQUEUE otherwise it doesn't get scheduled
schedule(applicationContext, settingsManager, CANCEL_AND_REENQUEUE)
}
}
}
private suspend fun doBackup(): Result {
var result: Result = Result.success()
try {
Log.i(TAG, "Starting APK backup... (stopped: $isStopped)")
if (!isStopped) apkBackupManager.backup()
} catch (e: Exception) {
Log.e(TAG, "Error backing up APKs: ", e)
result = Result.retry()
} finally {
Log.i(TAG, "Requesting app data backup... (stopped: $isStopped)")
val requestSuccess = if (!isStopped && backupRequester.isBackupEnabled) {
Log.d(TAG, "Backup is enabled, request backup...")
backupRequester.requestBackup()
} else true
Log.d(TAG, "Have requested backup.")
if (!requestSuccess) result = Result.retry()
}
return result
}
private fun createForegroundInfo() = ForegroundInfo(
NOTIFICATION_ID_OBSERVER,
nm.getBackupNotification(""),
FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
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
}

View file

@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.worker
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val workerModule = module {
factory {
BackupRequester(
context = androidContext(),
backupManager = get(),
packageService = get(),
)
}
single {
ApkBackup(
pm = androidContext().packageManager,
crypto = get(),
settingsManager = get(),
metadataManager = get()
)
}
single {
ApkBackupManager(
context = androidContext(),
settingsManager = get(),
metadataManager = get(),
packageService = get(),
apkBackup = get(),
plugin = get(),
nm = get()
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -30,6 +30,11 @@
<string name="settings_backup_apk_dialog_disable">Disable app backup</string>
<string name="settings_backup_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>

View file

@ -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">

View file

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

View file

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

View file

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

View file

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

View file

@ -15,11 +15,9 @@ import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH
import com.stevesoltys.seedvault.metadata.BackupType
import com.stevesoltys.seedvault.metadata.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,

View file

@ -5,7 +5,6 @@ import android.app.backup.BackupTransport.TRANSPORT_NOT_INITIALIZED
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_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
}
}

View file

@ -0,0 +1,237 @@
package com.stevesoltys.seedvault.worker
import android.content.pm.ApplicationInfo
import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP
import android.content.pm.ApplicationInfo.FLAG_INSTALLED
import android.content.pm.ApplicationInfo.FLAG_STOPPED
import android.content.pm.PackageInfo
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.Runs
import io.mockk.andThenJust
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifyAll
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import java.io.IOException
import java.io.OutputStream
internal class ApkBackupManagerTest : TransportTest() {
private val packageService: PackageService = mockk()
private val apkBackup: ApkBackup = mockk()
private val plugin: StoragePlugin = mockk()
private val nm: BackupNotificationManager = mockk()
private val apkBackupManager = ApkBackupManager(
context = context,
settingsManager = settingsManager,
metadataManager = metadataManager,
packageService = packageService,
apkBackup = apkBackup,
plugin = plugin,
nm = nm,
)
private val metadataOutputStream = mockk<OutputStream>()
private val packageMetadata: PackageMetadata = mockk()
@Test
fun `Package state of app that is not stopped gets recorded as not-allowed`() = runBlocking {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
every {
metadataManager.getPackageMetadata(packageInfo.packageName)
} returns packageMetadata
every { packageMetadata.state } returns UNKNOWN_ERROR
every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs
every { settingsManager.backupApks() } returns false
expectFinalUpload()
every { nm.onApkBackupDone() } just Runs
apkBackupManager.backup()
verify {
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
metadataOutputStream.close()
}
}
@Test
fun `Package state of app gets recorded even if no previous state`() = runBlocking {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
every {
metadataManager.getPackageMetadata(packageInfo.packageName)
} returns null
every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs
every { settingsManager.backupApks() } returns false
expectFinalUpload()
every { nm.onApkBackupDone() } just Runs
apkBackupManager.backup()
verify {
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
metadataOutputStream.close()
}
}
@Test
fun `Package state of app that is stopped gets recorded`() = runBlocking {
val packageInfo = PackageInfo().apply {
packageName = "org.example"
applicationInfo = mockk<ApplicationInfo> {
flags = FLAG_ALLOW_BACKUP or FLAG_INSTALLED or FLAG_STOPPED
}
}
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
every {
metadataManager.getPackageMetadata(packageInfo.packageName)
} returns packageMetadata
every { packageMetadata.state } returns UNKNOWN_ERROR
every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED) } just Runs
every { settingsManager.backupApks() } returns false
expectFinalUpload()
every { nm.onApkBackupDone() } just Runs
apkBackupManager.backup()
verify {
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED)
metadataOutputStream.close()
}
}
@Test
fun `Package state only updated when changed`() = runBlocking {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
every {
metadataManager.getPackageMetadata(packageInfo.packageName)
} returns packageMetadata
every { packageMetadata.state } returns NOT_ALLOWED
every { settingsManager.backupApks() } returns false
expectFinalUpload()
every { nm.onApkBackupDone() } just Runs
apkBackupManager.backup()
verifyAll(inverse = true) {
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
}
}
@Test
fun `two packages get backed up, one their APK uploaded`() = runBlocking {
val notAllowedPackages = listOf(
PackageInfo().apply { packageName = "org.example.1" },
PackageInfo().apply {
packageName = "org.example.2"
// the second package does not get backed up, because it is stopped
applicationInfo = mockk {
flags = FLAG_STOPPED
}
}
)
expectAllAppsWillGetBackedUp()
every { settingsManager.backupApks() } returns true
every { packageService.allUserPackages } returns notAllowedPackages
// update notification
every {
nm.onApkBackup(notAllowedPackages[0].packageName, any(), 0, notAllowedPackages.size)
} just Runs
// no backup needed
coEvery {
apkBackup.backupApkIfNecessary(notAllowedPackages[0], any())
} returns null
// update notification for second package
every {
nm.onApkBackup(notAllowedPackages[1].packageName, any(), 1, notAllowedPackages.size)
} just Runs
// was backed up, get new packageMetadata
coEvery {
apkBackup.backupApkIfNecessary(notAllowedPackages[1], any())
} returns packageMetadata
every { metadataManager.onApkBackedUp(notAllowedPackages[1], packageMetadata) } just Runs
expectFinalUpload()
every { nm.onApkBackupDone() } just Runs
apkBackupManager.backup()
coVerify {
apkBackup.backupApkIfNecessary(notAllowedPackages[0], any())
apkBackup.backupApkIfNecessary(notAllowedPackages[1], any())
metadataOutputStream.close()
}
}
@Test
fun `we keep trying to upload metadata at the end`() = runBlocking {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
every {
metadataManager.getPackageMetadata(packageInfo.packageName)
} returns packageMetadata
every { packageMetadata.state } returns UNKNOWN_ERROR
every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs
every { settingsManager.backupApks() } returns false
// final upload
every { settingsManager.getToken() } returns token
coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
every {
metadataManager.uploadMetadata(metadataOutputStream)
} throws IOException() andThenThrows SecurityException() andThenJust Runs
every { metadataOutputStream.close() } just Runs
every { nm.onApkBackupDone() } just Runs
apkBackupManager.backup()
verify {
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
metadataOutputStream.close()
}
}
private fun expectAllAppsWillGetBackedUp() {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns emptyList()
}
private fun expectFinalUpload() {
every { settingsManager.getToken() } returns token
coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
every { metadataManager.uploadMetadata(metadataOutputStream) } just Runs
every { metadataOutputStream.close() } just Runs
}
}

View file

@ -1,4 +1,9 @@
package com.stevesoltys.seedvault.transport.backup
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.worker
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
import android.content.pm.ApplicationInfo.FLAG_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())