Request backoff when asked to backup to network storage while no internet available

K/V backups are normally only attempted when charging and having an (un-metered) internet connection. However, if the system could not do a backup for more than a day, it ignores these requirements and still attempts a backup run. If a backup storage is used that is only accessible on the internet, but there is no internet connection, the backup attempt will fail. Therefore, we check if our storage requires the internet and if so, we treat it similar to a removable storage, by rejecting backup attempts and suppressing error notifications.
This commit is contained in:
Torsten Grote 2020-10-19 15:44:36 -03:00
parent f356f56746
commit 7401ead553
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
7 changed files with 44 additions and 19 deletions

View file

@ -25,7 +25,8 @@ It uses the same internal APIs as `adb backup` which is deprecated and thus need
## Permissions ## Permissions
* `android.permission.BACKUP` to back up application data. * `android.permission.BACKUP` to back up application data.
* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots. * `android.permission.ACCESS_NETWORK_STATE` to check if there is internet access when network storage is used.
* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots.
* `android.permission.MANAGE_USB` to access the serial number of USB mass storage devices. * `android.permission.MANAGE_USB` to access the serial number of USB mass storage devices.
* `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings and enable call log backup. * `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings and enable call log backup.
* `android.permission.QUERY_ALL_PACKAGES` to get information about all installed apps for backup. * `android.permission.QUERY_ALL_PACKAGES` to get information about all installed apps for backup.

View file

@ -9,6 +9,9 @@
android:name="android.permission.BACKUP" android:name="android.permission.BACKUP"
tools:ignore="ProtectedPermissions" /> tools:ignore="ProtectedPermissions" />
<!-- This is needed to check for internet access when backup is stored on network storage -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- This is needed to retrieve the available storage roots --> <!-- This is needed to retrieve the available storage roots -->
<uses-permission <uses-permission
android:name="android.permission.MANAGE_DOCUMENTS" android:name="android.permission.MANAGE_DOCUMENTS"

View file

@ -16,6 +16,7 @@ internal const val PREF_KEY_BACKUP_APK = "backup_apk"
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"
private const val PREF_KEY_STORAGE_IS_USB = "storageIsUsb" private const val PREF_KEY_STORAGE_IS_USB = "storageIsUsb"
private const val PREF_KEY_STORAGE_REQUIRES_NETWORK = "storageRequiresNetwork"
private const val PREF_KEY_FLASH_DRIVE_NAME = "flashDriveName" private const val PREF_KEY_FLASH_DRIVE_NAME = "flashDriveName"
private const val PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER = "flashSerialNumber" private const val PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER = "flashSerialNumber"
@ -63,6 +64,7 @@ class SettingsManager(context: Context) {
.putString(PREF_KEY_STORAGE_URI, storage.uri.toString()) .putString(PREF_KEY_STORAGE_URI, storage.uri.toString())
.putString(PREF_KEY_STORAGE_NAME, storage.name) .putString(PREF_KEY_STORAGE_NAME, storage.name)
.putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb) .putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb)
.putBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, storage.requiresNetwork)
.apply() .apply()
} }
@ -72,7 +74,8 @@ class SettingsManager(context: Context) {
val name = prefs.getString(PREF_KEY_STORAGE_NAME, null) val name = prefs.getString(PREF_KEY_STORAGE_NAME, null)
?: throw IllegalStateException("no storage name") ?: throw IllegalStateException("no storage name")
val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false) val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false)
return Storage(uri, name, isUsb) val requiresNetwork = prefs.getBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, false)
return Storage(uri, name, isUsb, requiresNetwork)
} }
fun setFlashDrive(usb: FlashDrive?) { fun setFlashDrive(usb: FlashDrive?) {
@ -119,7 +122,8 @@ class SettingsManager(context: Context) {
data class Storage( data class Storage(
val uri: Uri, val uri: Uri,
val name: String, val name: String,
val isUsb: Boolean val isUsb: Boolean,
val requiresNetwork: Boolean
) { ) {
fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, uri) fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, uri)
?: throw AssertionError("Should only happen on API < 21.") ?: throw AssertionError("Should only happen on API < 21.")

View file

@ -14,6 +14,8 @@ import android.app.backup.RestoreSet
import android.content.Context import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.net.ConnectivityManager
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
@ -32,6 +34,7 @@ import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit.DAYS import java.util.concurrent.TimeUnit.DAYS
import java.util.concurrent.TimeUnit.HOURS
private val TAG = BackupCoordinator::class.java.simpleName private val TAG = BackupCoordinator::class.java.simpleName
@ -212,12 +215,11 @@ internal class BackupCoordinator(
): Int { ): Int {
cancelReason = UNKNOWN_ERROR cancelReason = UNKNOWN_ERROR
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
if (packageName == MAGIC_PACKAGE_MANAGER) { // K/V backups (typically starting with package manager metadata)
// backups of package manager metadata 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 now is not a good time for a backup.
if (getBackupBackoff() != 0L) { if (packageName == MAGIC_PACKAGE_MANAGER && getBackupBackoff() != 0L) {
return TRANSPORT_PACKAGE_REJECTED return TRANSPORT_PACKAGE_REJECTED
}
} }
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) {
@ -430,14 +432,23 @@ internal class BackupCoordinator(
private fun getBackupBackoff(): Long { private fun getBackupBackoff(): Long {
val noBackoff = 0L val noBackoff = 0L
val defaultBackoff = DAYS.toMillis(30) val longBackoff = DAYS.toMillis(30)
// back off if there's no storage set // back off if there's no storage set
val storage = settingsManager.getStorage() ?: return defaultBackoff val storage = settingsManager.getStorage() ?: return longBackoff
// don't back off if storage is not ejectable or available right now
return if (!storage.isUsb || storage.getDocumentFile(context).isDirectory) noBackoff // back off if storage is removable and not available right now
// otherwise back off return if (storage.isUsb && !storage.getDocumentFile(context).isDirectory) longBackoff
else defaultBackoff // back off if storage is on network, but we have no access
else if (storage.requiresNetwork && !hasInternet()) HOURS.toMillis(1)
// otherwise no back off
else noBackoff
}
private fun hasInternet(): Boolean {
val cm = context.getSystemService(ConnectivityManager::class.java)
val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
return capabilities.hasCapability(NET_CAPABILITY_INTERNET)
} }
} }

View file

@ -22,6 +22,7 @@ import android.provider.DocumentsContract.Root.COLUMN_ICON
import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID
import android.provider.DocumentsContract.Root.COLUMN_SUMMARY import android.provider.DocumentsContract.Root.COLUMN_SUMMARY
import android.provider.DocumentsContract.Root.COLUMN_TITLE import android.provider.DocumentsContract.Root.COLUMN_TITLE
import android.provider.DocumentsContract.Root.FLAG_LOCAL_ONLY
import android.provider.DocumentsContract.Root.FLAG_REMOVABLE_USB import android.provider.DocumentsContract.Root.FLAG_REMOVABLE_USB
import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_CREATE import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_CREATE
import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
@ -50,6 +51,7 @@ data class StorageRoot(
internal val summary: String?, internal val summary: String?,
internal val availableBytes: Long?, internal val availableBytes: Long?,
internal val isUsb: Boolean, internal val isUsb: Boolean,
internal val requiresNetwork: Boolean,
internal val enabled: Boolean = true, internal val enabled: Boolean = true,
internal val overrideClickListener: (() -> Unit)? = null internal val overrideClickListener: (() -> Unit)? = null
) { ) {
@ -144,10 +146,11 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
if (!supportsCreate || !supportsIsChild) return null if (!supportsCreate || !supportsIsChild) return null
val rootId = cursor.getString(COLUMN_ROOT_ID)!! val rootId = cursor.getString(COLUMN_ROOT_ID)!!
if (authority == AUTHORITY_STORAGE && rootId == ROOT_ID_HOME) return null if (authority == AUTHORITY_STORAGE && rootId == ROOT_ID_HOME) return null
val documentId = cursor.getString(COLUMN_DOCUMENT_ID) ?: return null
return StorageRoot( return StorageRoot(
authority = authority, authority = authority,
rootId = rootId, rootId = rootId,
documentId = cursor.getString(COLUMN_DOCUMENT_ID)!!, documentId = documentId,
icon = getIcon(context, authority, rootId, cursor.getInt(COLUMN_ICON)), icon = getIcon(context, authority, rootId, cursor.getInt(COLUMN_ICON)),
title = cursor.getString(COLUMN_TITLE)!!, title = cursor.getString(COLUMN_TITLE)!!,
summary = cursor.getString(COLUMN_SUMMARY), summary = cursor.getString(COLUMN_SUMMARY),
@ -155,7 +158,8 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
// AOSP 11 reports -1 instead of null // AOSP 11 reports -1 instead of null
if (bytes == -1L) null else bytes if (bytes == -1L) null else bytes
}, },
isUsb = flags and FLAG_REMOVABLE_USB != 0 isUsb = flags and FLAG_REMOVABLE_USB != 0,
requiresNetwork = flags and FLAG_LOCAL_ONLY == 0 // not local only == requires network
) )
} }
@ -175,6 +179,7 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
summary = context.getString(R.string.storage_fake_drive_summary), summary = context.getString(R.string.storage_fake_drive_summary),
availableBytes = null, availableBytes = null,
isUsb = true, isUsb = true,
requiresNetwork = false,
enabled = false enabled = false
) )
roots.add(root) roots.add(root)
@ -216,6 +221,7 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
summary = context.getString(summaryRes), summary = context.getString(summaryRes),
availableBytes = null, availableBytes = null,
isUsb = false, isUsb = false,
requiresNetwork = true,
enabled = !isInstalled || isRestore, enabled = !isInstalled || isRestore,
overrideClickListener = { overrideClickListener = {
if (isInstalled) context.startActivity(intent) if (isInstalled) context.startActivity(intent)

View file

@ -101,7 +101,7 @@ internal abstract class StorageViewModel(
} else { } else {
root.title root.title
} }
val storage = Storage(uri, name, root.isUsb) val storage = Storage(uri, name, root.isUsb, root.requiresNetwork)
settingsManager.setStorage(storage) settingsManager.setStorage(storage)
if (storage.isUsb) { if (storage.isUsb) {

View file

@ -63,7 +63,7 @@ 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(), false) private val storage = Storage(Uri.EMPTY, getRandomString(), false, false)
@Test @Test
fun `starting a new restore set works as expected`() = runBlocking { fun `starting a new restore set works as expected`() = runBlocking {