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, name: String,
mimeType: String = MIME_TYPE mimeType: String = MIME_TYPE
): DocumentFile { ): DocumentFile {
return findFileBlocking(context, name) ?: createFile(mimeType, name)?.apply { return try {
findFileBlocking(context, name) ?: createFile(mimeType, name)?.apply {
if (this.name != name) { if (this.name != name) {
throw IOException("File named ${this.name}, but should be $name") throw IOException("File named ${this.name}, but should be $name")
} }
} ?: throw IOException() } ?: 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() { private suspend fun setMenuItemStates() {
if (menuBackupNow != null && menuRestore != null) { if (menuBackupNow != null && menuRestore != null) {
val storage = this.storage val enabled = withContext(Dispatchers.IO) {
val enabled = storage != null && storageAvailable(storage) settingsManager.canDoBackupNow()
}
menuBackupNow?.isEnabled = enabled menuBackupNow?.isEnabled = enabled
menuRestore?.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.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import java.util.concurrent.ConcurrentSkipListSet import java.util.concurrent.ConcurrentSkipListSet
internal const val PREF_KEY_TOKEN = "token" internal const val PREF_KEY_TOKEN = "token"
internal const val PREF_KEY_BACKUP_APK = "backup_apk" internal const val PREF_KEY_BACKUP_APK = "backup_apk"
internal const val PREF_KEY_REDO_PM = "redoPm"
private const val PREF_KEY_STORAGE_URI = "storageUri" private const val PREF_KEY_STORAGE_URI = "storageUri"
private const val PREF_KEY_STORAGE_NAME = "storageName" private const val PREF_KEY_STORAGE_NAME = "storageName"
@ -28,7 +30,7 @@ private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId"
private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist" private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist"
class SettingsManager(context: Context) { class SettingsManager(private val context: Context) {
private val prefs = permitDiskReads { private val prefs = permitDiskReads {
PreferenceManager.getDefaultSharedPreferences(context) PreferenceManager.getDefaultSharedPreferences(context)
@ -107,6 +109,29 @@ class SettingsManager(context: Context) {
return FlashDrive(name, serialNumber, vendorId, productId) 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 { fun backupApks(): Boolean {
return prefs.getBoolean(PREF_KEY_BACKUP_APK, true) 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 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 Steve Soltys
* @author Torsten Grote * @author Torsten Grote
@ -55,9 +72,12 @@ internal class BackupCoordinator(
private val nm: BackupNotificationManager private val nm: BackupNotificationManager
) { ) {
private var calledInitialize = false private val state = CoordinatorState(
private var calledClearBackupData = false calledInitialize = false,
private var cancelReason: PackageState = UNKNOWN_ERROR calledClearBackupData = false,
skippedPmBackup = false,
cancelReason = UNKNOWN_ERROR
)
// ------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------
// Transport initialization and quota // Transport initialization and quota
@ -108,12 +128,12 @@ internal class BackupCoordinator(
} }
// [finishBackup] will only be called when we return [TRANSPORT_OK] here // [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully // so we remember that we initialized successfully
calledInitialize = true state.calledInitialize = true
TRANSPORT_OK TRANSPORT_OK
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error initializing device", e) Log.e(TAG, "Error initializing device", e)
// Show error notification if we were ready for backups // Show error notification if we were ready for backups
if (getBackupBackoff() == 0L) nm.onBackupError() if (settingsManager.canDoBackupNow()) nm.onBackupError()
TRANSPORT_ERROR TRANSPORT_ERROR
} }
@ -211,13 +231,30 @@ internal class BackupCoordinator(
data: ParcelFileDescriptor, data: ParcelFileDescriptor,
flags: Int flags: Int
): Int { ): Int {
cancelReason = UNKNOWN_ERROR state.cancelReason = UNKNOWN_ERROR
val packageName = packageInfo.packageName 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. // 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. // We need to reject them manually when we can not do a backup now.
if (packageName == MAGIC_PACKAGE_MANAGER && getBackupBackoff() != 0L) { // What else we tried can be found in: https://github.com/stevesoltys/seedvault/issues/102
return TRANSPORT_PACKAGE_REJECTED 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) val result = kv.performBackup(packageInfo, data, flags)
if (result == TRANSPORT_OK && packageName == MAGIC_PACKAGE_MANAGER) { if (result == TRANSPORT_OK && packageName == MAGIC_PACKAGE_MANAGER) {
@ -250,8 +287,8 @@ internal class BackupCoordinator(
fun checkFullBackupSize(size: Long): Int { fun checkFullBackupSize(size: Long): Int {
val result = full.checkFullBackupSize(size) val result = full.checkFullBackupSize(size)
if (result == TRANSPORT_PACKAGE_REJECTED) cancelReason = NO_DATA if (result == TRANSPORT_PACKAGE_REJECTED) state.cancelReason = NO_DATA
else if (result == TRANSPORT_QUOTA_EXCEEDED) cancelReason = QUOTA_EXCEEDED else if (result == TRANSPORT_QUOTA_EXCEEDED) state.cancelReason = QUOTA_EXCEEDED
return result return result
} }
@ -260,7 +297,7 @@ internal class BackupCoordinator(
fileDescriptor: ParcelFileDescriptor, fileDescriptor: ParcelFileDescriptor,
flags: Int flags: Int
): Int { ): Int {
cancelReason = UNKNOWN_ERROR state.cancelReason = UNKNOWN_ERROR
return full.performFullBackup(targetPackage, fileDescriptor, flags) return full.performFullBackup(targetPackage, fileDescriptor, flags)
} }
@ -282,7 +319,10 @@ internal class BackupCoordinator(
suspend fun cancelFullBackup() { suspend fun cancelFullBackup() {
val packageInfo = full.getCurrentPackage() val packageInfo = full.getCurrentPackage()
?: throw AssertionError("Cancelling full backup, but no current package") ?: 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) onPackageBackupError(packageInfo)
full.cancelFullBackup() full.cancelFullBackup()
} }
@ -313,13 +353,13 @@ internal class BackupCoordinator(
Log.w(TAG, "Error clearing full backup data for $packageName", e) Log.w(TAG, "Error clearing full backup data for $packageName", e)
return TRANSPORT_ERROR return TRANSPORT_ERROR
} }
calledClearBackupData = true state.calledClearBackupData = true
return TRANSPORT_OK return TRANSPORT_OK
} }
/** /**
* Finish sending application data to the backup destination. * Finish sending application data to the backup destination. This must be called
* This must be called after [performIncrementalBackup], [performFullBackup], or [clearBackupData] * after [performIncrementalBackup], [performFullBackup], or [clearBackupData]
* to ensure that all data is sent and the operation properly finalized. * 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. * 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 onPackageBackedUp(full.getCurrentPackage()!!) // not-null because we have state
full.finishBackup() full.finishBackup()
} }
calledInitialize || calledClearBackupData -> { state.expectFinish -> {
calledInitialize = false state.onFinish()
calledClearBackupData = false
TRANSPORT_OK TRANSPORT_OK
} }
else -> throw IllegalStateException("Unexpected state in finishBackup()") else -> throw IllegalStateException("Unexpected state in finishBackup()")
@ -405,23 +444,24 @@ internal class BackupCoordinator(
} }
private suspend fun onPackageBackedUp(packageInfo: PackageInfo) { private suspend fun onPackageBackedUp(packageInfo: PackageInfo) {
val packageName = packageInfo.packageName
try { try {
plugin.getMetadataOutputStream().use { plugin.getMetadataOutputStream().use {
metadataManager.onPackageBackedUp(packageInfo, it) metadataManager.onPackageBackedUp(packageInfo, it)
} }
} catch (e: IOException) { } 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) { private suspend fun onPackageBackupError(packageInfo: PackageInfo) {
// don't bother with system apps that have no data // 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 val packageName = packageInfo.packageName
try { try {
plugin.getMetadataOutputStream().use { plugin.getMetadataOutputStream().use {
metadataManager.onPackageBackupError(packageInfo, cancelReason, it) metadataManager.onPackageBackupError(packageInfo, state.cancelReason, it)
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error while writing metadata for $packageName", e) Log.e(TAG, "Error while writing metadata for $packageName", e)

View file

@ -71,7 +71,10 @@ internal class KVBackup(
this.state = KVBackupState(packageInfo) this.state = KVBackupState(packageInfo)
// no need for backup when no data has changed // 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 // check if we have existing data for the given package
val hasDataForPackage = try { val hasDataForPackage = try {

View file

@ -1,6 +1,8 @@
package com.stevesoltys.seedvault.transport.backup 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_ERROR
import android.app.backup.BackupTransport.TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED
import android.app.backup.BackupTransport.TRANSPORT_OK import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
@ -62,7 +64,9 @@ internal class BackupCoordinatorTest : BackupTest() {
private val metadataOutputStream = mockk<OutputStream>() private val metadataOutputStream = mockk<OutputStream>()
private val fileDescriptor: ParcelFileDescriptor = mockk() private val fileDescriptor: ParcelFileDescriptor = mockk()
private val packageMetadata: PackageMetadata = mockk() private val packageMetadata: PackageMetadata = mockk()
private val storage = Storage(Uri.EMPTY, getRandomString(), private val storage = Storage(
uri = Uri.EMPTY,
name = getRandomString(),
isUsb = false, isUsb = false,
requiresNetwork = false requiresNetwork = false
) )
@ -106,7 +110,7 @@ internal class BackupCoordinatorTest : BackupTest() {
fun `error notification when device initialization fails`() = runBlocking { fun `error notification when device initialization fails`() = runBlocking {
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } throws IOException() coEvery { plugin.initializeDevice() } throws IOException()
every { settingsManager.getStorage() } returns storage every { settingsManager.canDoBackupNow() } returns true
every { notificationManager.onBackupError() } just Runs every { notificationManager.onBackupError() } just Runs
assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
@ -120,14 +124,11 @@ internal class BackupCoordinatorTest : BackupTest() {
} }
@Test @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 { runBlocking {
val storage = mockk<Storage>()
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } throws IOException() coEvery { plugin.initializeDevice() } throws IOException()
every { settingsManager.getStorage() } returns storage every { settingsManager.canDoBackupNow() } returns false
every { storage.isUnavailableUsb(context) } returns true
assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) 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 @Test
fun `getBackupQuota() delegates to right plugin`() = runBlocking { fun `getBackupQuota() delegates to right plugin`() = runBlocking {
val isFullBackup = Random.nextBoolean() val isFullBackup = Random.nextBoolean()
@ -330,7 +353,7 @@ internal class BackupCoordinatorTest : BackupTest() {
) )
val packageMetadata: PackageMetadata = mockk() val packageMetadata: PackageMetadata = mockk()
every { settingsManager.getStorage() } returns storage // to check for removable storage every { settingsManager.canDoBackupNow() } returns true
// do actual @pm@ backup // do actual @pm@ backup
coEvery { kv.performBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK 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 // 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 @Test
fun `package with no new data comes back ok right away`() = runBlocking { 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)) assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_DATA_NOT_CHANGED))
assertTrue(backup.hasState()) assertTrue(backup.hasState())
verify { data.close() }
every { plugin.packageFinished(packageInfo) } just Runs every { plugin.packageFinished(packageInfo) } just Runs
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertEquals(TRANSPORT_OK, backup.finishBackup())