Pretend to make successful @pm@ backup when no backup is possible

This is the same behavior as Google backup when it has no internet connection and after extensive research the only option we can keep the system from considering the backup state to be compromised.

K/V backups are run at least every day, no matter what backup interval we set in settings and when they run, we don't get asked before, if now is a good time for backups. So we need to fake an OK for @pm@ backup and can error out afterwards without compromising state.
This commit is contained in:
Torsten Grote 2020-10-21 10:31:09 -03:00
parent 141fe7575d
commit e2f0d19f77
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
7 changed files with 143 additions and 46 deletions

View file

@ -177,11 +177,17 @@ internal suspend fun DocumentFile.createOrGetFile(
name: String,
mimeType: String = MIME_TYPE
): DocumentFile {
return findFileBlocking(context, name) ?: createFile(mimeType, name)?.apply {
return try {
findFileBlocking(context, name) ?: createFile(mimeType, name)?.apply {
if (this.name != name) {
throw IOException("File named ${this.name}, but should be $name")
}
} ?: throw IOException()
} catch (e: IllegalArgumentException) {
// Can be thrown by FileSystemProvider#isChildDocument() when flash drive is not plugged-in
// http://aosp.opersys.com/xref/android-11.0.0_r8/xref/frameworks/base/core/java/com/android/internal/content/FileSystemProvider.java#135
throw IOException(e)
}
}
/**

View file

@ -225,16 +225,12 @@ class SettingsFragment : PreferenceFragmentCompat() {
private suspend fun setMenuItemStates() {
if (menuBackupNow != null && menuRestore != null) {
val storage = this.storage
val enabled = storage != null && storageAvailable(storage)
val enabled = withContext(Dispatchers.IO) {
settingsManager.canDoBackupNow()
}
menuBackupNow?.isEnabled = enabled
menuRestore?.isEnabled = enabled
}
}
private suspend fun storageAvailable(storage: Storage) = withContext(Dispatchers.IO) {
val context = context ?: return@withContext false
!storage.isUnavailableUsb(context) && !storage.isUnavailableNetwork(context)
}
}

View file

@ -9,12 +9,14 @@ import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
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_REDO_PM = "redoPm"
private const val PREF_KEY_STORAGE_URI = "storageUri"
private const val PREF_KEY_STORAGE_NAME = "storageName"
@ -28,7 +30,7 @@ private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId"
private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist"
class SettingsManager(context: Context) {
class SettingsManager(private val context: Context) {
private val prefs = permitDiskReads {
PreferenceManager.getDefaultSharedPreferences(context)
@ -107,6 +109,29 @@ class SettingsManager(context: Context) {
return FlashDrive(name, serialNumber, vendorId, productId)
}
/**
* Check if we are able to do backups now by examining possible pre-conditions
* such as plugged-in flash drive or internet access.
*
* Should be run off the UI thread (ideally I/O) because of disk access.
*
* @return true if a backup is possible, false if not.
*/
@WorkerThread
fun canDoBackupNow(): Boolean {
val storage = getStorage() ?: return false
return !storage.isUnavailableUsb(context) && !storage.isUnavailableNetwork(context)
}
/**
* Set this to true if the next backup run for [MAGIC_PACKAGE_MANAGER]
* needs to be non-incremental,
* because we need to fake an OK backup now even though we can't do one right now.
*/
var pmBackupNextTimeNonIncremental: Boolean
get() = prefs.getBoolean(PREF_KEY_REDO_PM, false)
set(value) = prefs.edit().putBoolean(PREF_KEY_REDO_PM, value).apply()
fun backupApks(): Boolean {
return prefs.getBoolean(PREF_KEY_BACKUP_APK, true)
}

View file

@ -36,6 +36,23 @@ import java.util.concurrent.TimeUnit.HOURS
private val TAG = BackupCoordinator::class.java.simpleName
private class CoordinatorState(
var calledInitialize: Boolean,
var calledClearBackupData: Boolean,
var skippedPmBackup: Boolean,
var cancelReason: PackageState
) {
val expectFinish: Boolean
get() = calledInitialize || calledClearBackupData || skippedPmBackup
fun onFinish() {
calledInitialize = false
calledClearBackupData = false
skippedPmBackup = false
cancelReason = UNKNOWN_ERROR
}
}
/**
* @author Steve Soltys
* @author Torsten Grote
@ -55,9 +72,12 @@ internal class BackupCoordinator(
private val nm: BackupNotificationManager
) {
private var calledInitialize = false
private var calledClearBackupData = false
private var cancelReason: PackageState = UNKNOWN_ERROR
private val state = CoordinatorState(
calledInitialize = false,
calledClearBackupData = false,
skippedPmBackup = false,
cancelReason = UNKNOWN_ERROR
)
// ------------------------------------------------------------------------------------
// Transport initialization and quota
@ -108,12 +128,12 @@ internal class BackupCoordinator(
}
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully
calledInitialize = true
state.calledInitialize = true
TRANSPORT_OK
} catch (e: IOException) {
Log.e(TAG, "Error initializing device", e)
// Show error notification if we were ready for backups
if (getBackupBackoff() == 0L) nm.onBackupError()
if (settingsManager.canDoBackupNow()) nm.onBackupError()
TRANSPORT_ERROR
}
@ -211,13 +231,30 @@ internal class BackupCoordinator(
data: ParcelFileDescriptor,
flags: Int
): Int {
cancelReason = UNKNOWN_ERROR
state.cancelReason = UNKNOWN_ERROR
val packageName = packageInfo.packageName
// K/V backups (typically starting with package manager metadata)
// K/V backups (typically starting with package manager metadata - @pm@)
// are scheduled with JobInfo.Builder#setOverrideDeadline() and thus do not respect backoff.
// We need to reject them manually when now is not a good time for a backup.
if (packageName == MAGIC_PACKAGE_MANAGER && getBackupBackoff() != 0L) {
return TRANSPORT_PACKAGE_REJECTED
// We need to reject them manually when we can not do a backup now.
// What else we tried can be found in: https://github.com/stevesoltys/seedvault/issues/102
if (packageName == MAGIC_PACKAGE_MANAGER) {
if (!settingsManager.canDoBackupNow()) {
// Returning anything else here (except non-incremental-required which re-tries)
// will make the system consider the backup state compromised
// and force re-initialization on next run.
// Errors for other packages are OK, but this one is not allowed to fail.
Log.w(TAG, "Skipping @pm@ backup as we can't do backup right now.")
state.skippedPmBackup = true
settingsManager.pmBackupNextTimeNonIncremental = true
data.close()
return TRANSPORT_OK
} else if (flags and FLAG_INCREMENTAL != 0 &&
settingsManager.pmBackupNextTimeNonIncremental
) {
settingsManager.pmBackupNextTimeNonIncremental = false
data.close()
return TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED
}
}
val result = kv.performBackup(packageInfo, data, flags)
if (result == TRANSPORT_OK && packageName == MAGIC_PACKAGE_MANAGER) {
@ -250,8 +287,8 @@ internal class BackupCoordinator(
fun checkFullBackupSize(size: Long): Int {
val result = full.checkFullBackupSize(size)
if (result == TRANSPORT_PACKAGE_REJECTED) cancelReason = NO_DATA
else if (result == TRANSPORT_QUOTA_EXCEEDED) cancelReason = QUOTA_EXCEEDED
if (result == TRANSPORT_PACKAGE_REJECTED) state.cancelReason = NO_DATA
else if (result == TRANSPORT_QUOTA_EXCEEDED) state.cancelReason = QUOTA_EXCEEDED
return result
}
@ -260,7 +297,7 @@ internal class BackupCoordinator(
fileDescriptor: ParcelFileDescriptor,
flags: Int
): Int {
cancelReason = UNKNOWN_ERROR
state.cancelReason = UNKNOWN_ERROR
return full.performFullBackup(targetPackage, fileDescriptor, flags)
}
@ -282,7 +319,10 @@ internal class BackupCoordinator(
suspend fun cancelFullBackup() {
val packageInfo = full.getCurrentPackage()
?: throw AssertionError("Cancelling full backup, but no current package")
Log.i(TAG, "Cancel full backup of ${packageInfo.packageName} because of $cancelReason")
Log.i(
TAG, "Cancel full backup of ${packageInfo.packageName}" +
" because of $state.cancelReason"
)
onPackageBackupError(packageInfo)
full.cancelFullBackup()
}
@ -313,13 +353,13 @@ internal class BackupCoordinator(
Log.w(TAG, "Error clearing full backup data for $packageName", e)
return TRANSPORT_ERROR
}
calledClearBackupData = true
state.calledClearBackupData = true
return TRANSPORT_OK
}
/**
* Finish sending application data to the backup destination.
* This must be called after [performIncrementalBackup], [performFullBackup], or [clearBackupData]
* Finish sending application data to the backup destination. This must be called
* after [performIncrementalBackup], [performFullBackup], or [clearBackupData]
* to ensure that all data is sent and the operation properly finalized.
* Only when this method returns true can a backup be assumed to have succeeded.
*
@ -340,9 +380,8 @@ internal class BackupCoordinator(
onPackageBackedUp(full.getCurrentPackage()!!) // not-null because we have state
full.finishBackup()
}
calledInitialize || calledClearBackupData -> {
calledInitialize = false
calledClearBackupData = false
state.expectFinish -> {
state.onFinish()
TRANSPORT_OK
}
else -> throw IllegalStateException("Unexpected state in finishBackup()")
@ -405,23 +444,24 @@ internal class BackupCoordinator(
}
private suspend fun onPackageBackedUp(packageInfo: PackageInfo) {
val packageName = packageInfo.packageName
try {
plugin.getMetadataOutputStream().use {
metadataManager.onPackageBackedUp(packageInfo, it)
}
} catch (e: IOException) {
Log.e(TAG, "Error while writing metadata for $packageName", e)
Log.e(TAG, "Error while writing metadata for ${packageInfo.packageName}", e)
// we are not re-throwing this as there's nothing we can do now
// except hoping the current metadata gets written with the next package
}
}
private suspend fun onPackageBackupError(packageInfo: PackageInfo) {
// don't bother with system apps that have no data
if (cancelReason == NO_DATA && packageInfo.isSystemApp()) return
if (state.cancelReason == NO_DATA && packageInfo.isSystemApp()) return
val packageName = packageInfo.packageName
try {
plugin.getMetadataOutputStream().use {
metadataManager.onPackageBackupError(packageInfo, cancelReason, it)
metadataManager.onPackageBackupError(packageInfo, state.cancelReason, it)
}
} catch (e: IOException) {
Log.e(TAG, "Error while writing metadata for $packageName", e)

View file

@ -71,7 +71,10 @@ internal class KVBackup(
this.state = KVBackupState(packageInfo)
// no need for backup when no data has changed
if (dataNotChanged) return TRANSPORT_OK
if (dataNotChanged) {
data.close()
return TRANSPORT_OK
}
// check if we have existing data for the given package
val hasDataForPackage = try {

View file

@ -1,6 +1,8 @@
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.BackupTransport.FLAG_INCREMENTAL
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
@ -62,7 +64,9 @@ internal class BackupCoordinatorTest : BackupTest() {
private val metadataOutputStream = mockk<OutputStream>()
private val fileDescriptor: ParcelFileDescriptor = mockk()
private val packageMetadata: PackageMetadata = mockk()
private val storage = Storage(Uri.EMPTY, getRandomString(),
private val storage = Storage(
uri = Uri.EMPTY,
name = getRandomString(),
isUsb = false,
requiresNetwork = false
)
@ -106,7 +110,7 @@ internal class BackupCoordinatorTest : BackupTest() {
fun `error notification when device initialization fails`() = runBlocking {
every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } throws IOException()
every { settingsManager.getStorage() } returns storage
every { settingsManager.canDoBackupNow() } returns true
every { notificationManager.onBackupError() } just Runs
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
@ -120,14 +124,11 @@ internal class BackupCoordinatorTest : BackupTest() {
}
@Test
fun `no error notification when device initialization fails on unplugged USB storage`() =
fun `no error notification when device initialization fails when no backup possible`() =
runBlocking {
val storage = mockk<Storage>()
every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } throws IOException()
every { settingsManager.getStorage() } returns storage
every { storage.isUnavailableUsb(context) } returns true
every { settingsManager.canDoBackupNow() } returns false
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
@ -139,6 +140,28 @@ internal class BackupCoordinatorTest : BackupTest() {
}
}
@Test
fun `performIncrementalBackup fakes @pm@ when no backup possible`() = runBlocking {
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
every { settingsManager.canDoBackupNow() } returns false
every { settingsManager.pmBackupNextTimeNonIncremental = true } just Runs
every { data.close() } just Runs
// returns OK even though we can't do backups
assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, data, 0))
every { settingsManager.canDoBackupNow() } returns true
every { settingsManager.pmBackupNextTimeNonIncremental } returns true
every { settingsManager.pmBackupNextTimeNonIncremental = false } just Runs
// now that we can do backups again, it requests a full non-incremental backup of @pm@
assertEquals(
TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED,
backup.performIncrementalBackup(packageInfo, data, FLAG_INCREMENTAL)
)
}
@Test
fun `getBackupQuota() delegates to right plugin`() = runBlocking {
val isFullBackup = Random.nextBoolean()
@ -330,7 +353,7 @@ internal class BackupCoordinatorTest : BackupTest() {
)
val packageMetadata: PackageMetadata = mockk()
every { settingsManager.getStorage() } returns storage // to check for removable storage
every { settingsManager.canDoBackupNow() } returns true
// do actual @pm@ backup
coEvery { kv.performBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
// now check if we have opt-out apps that we need to back up APKs for

View file

@ -153,9 +153,13 @@ internal class KVBackupTest : BackupTest() {
@Test
fun `package with no new data comes back ok right away`() = runBlocking {
every { data.close() } just Runs
assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_DATA_NOT_CHANGED))
assertTrue(backup.hasState())
verify { data.close() }
every { plugin.packageFinished(packageInfo) } just Runs
assertEquals(TRANSPORT_OK, backup.finishBackup())