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:
parent
f356f56746
commit
7401ead553
7 changed files with 44 additions and 19 deletions
|
@ -25,6 +25,7 @@ It uses the same internal APIs as `adb backup` which is deprecated and thus need
|
|||
|
||||
## Permissions
|
||||
* `android.permission.BACKUP` to back up application data.
|
||||
* `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.WRITE_SECURE_SETTINGS` to change system backup settings and enable call log backup.
|
||||
|
|
|
@ -9,6 +9,9 @@
|
|||
android:name="android.permission.BACKUP"
|
||||
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 -->
|
||||
<uses-permission
|
||||
android:name="android.permission.MANAGE_DOCUMENTS"
|
||||
|
|
|
@ -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_NAME = "storageName"
|
||||
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_SERIAL_NUMBER = "flashSerialNumber"
|
||||
|
@ -63,6 +64,7 @@ class SettingsManager(context: Context) {
|
|||
.putString(PREF_KEY_STORAGE_URI, storage.uri.toString())
|
||||
.putString(PREF_KEY_STORAGE_NAME, storage.name)
|
||||
.putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb)
|
||||
.putBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, storage.requiresNetwork)
|
||||
.apply()
|
||||
}
|
||||
|
||||
|
@ -72,7 +74,8 @@ class SettingsManager(context: Context) {
|
|||
val name = prefs.getString(PREF_KEY_STORAGE_NAME, null)
|
||||
?: throw IllegalStateException("no storage name")
|
||||
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?) {
|
||||
|
@ -119,7 +122,8 @@ class SettingsManager(context: Context) {
|
|||
data class Storage(
|
||||
val uri: Uri,
|
||||
val name: String,
|
||||
val isUsb: Boolean
|
||||
val isUsb: Boolean,
|
||||
val requiresNetwork: Boolean
|
||||
) {
|
||||
fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, uri)
|
||||
?: throw AssertionError("Should only happen on API < 21.")
|
||||
|
|
|
@ -14,6 +14,8 @@ import android.app.backup.RestoreSet
|
|||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
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.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
|
@ -32,6 +34,7 @@ import com.stevesoltys.seedvault.settings.SettingsManager
|
|||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit.DAYS
|
||||
import java.util.concurrent.TimeUnit.HOURS
|
||||
|
||||
private val TAG = BackupCoordinator::class.java.simpleName
|
||||
|
||||
|
@ -212,12 +215,11 @@ internal class BackupCoordinator(
|
|||
): Int {
|
||||
cancelReason = UNKNOWN_ERROR
|
||||
val packageName = packageInfo.packageName
|
||||
if (packageName == MAGIC_PACKAGE_MANAGER) {
|
||||
// backups of package manager metadata do not respect backoff
|
||||
// we need to reject them manually when now is not a good time for a backup
|
||||
if (getBackupBackoff() != 0L) {
|
||||
return TRANSPORT_PACKAGE_REJECTED
|
||||
}
|
||||
// K/V backups (typically starting with package manager metadata)
|
||||
// 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
|
||||
}
|
||||
val result = kv.performBackup(packageInfo, data, flags)
|
||||
if (result == TRANSPORT_OK && packageName == MAGIC_PACKAGE_MANAGER) {
|
||||
|
@ -430,14 +432,23 @@ internal class BackupCoordinator(
|
|||
|
||||
private fun getBackupBackoff(): Long {
|
||||
val noBackoff = 0L
|
||||
val defaultBackoff = DAYS.toMillis(30)
|
||||
val longBackoff = DAYS.toMillis(30)
|
||||
|
||||
// back off if there's no storage set
|
||||
val storage = settingsManager.getStorage() ?: return defaultBackoff
|
||||
// don't back off if storage is not ejectable or available right now
|
||||
return if (!storage.isUsb || storage.getDocumentFile(context).isDirectory) noBackoff
|
||||
// otherwise back off
|
||||
else defaultBackoff
|
||||
val storage = settingsManager.getStorage() ?: return longBackoff
|
||||
|
||||
// back off if storage is removable and not available right now
|
||||
return if (storage.isUsb && !storage.getDocumentFile(context).isDirectory) longBackoff
|
||||
// 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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_SUMMARY
|
||||
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_SUPPORTS_CREATE
|
||||
import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
|
||||
|
@ -50,6 +51,7 @@ data class StorageRoot(
|
|||
internal val summary: String?,
|
||||
internal val availableBytes: Long?,
|
||||
internal val isUsb: Boolean,
|
||||
internal val requiresNetwork: Boolean,
|
||||
internal val enabled: Boolean = true,
|
||||
internal val overrideClickListener: (() -> Unit)? = null
|
||||
) {
|
||||
|
@ -144,10 +146,11 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
|
|||
if (!supportsCreate || !supportsIsChild) return null
|
||||
val rootId = cursor.getString(COLUMN_ROOT_ID)!!
|
||||
if (authority == AUTHORITY_STORAGE && rootId == ROOT_ID_HOME) return null
|
||||
val documentId = cursor.getString(COLUMN_DOCUMENT_ID) ?: return null
|
||||
return StorageRoot(
|
||||
authority = authority,
|
||||
rootId = rootId,
|
||||
documentId = cursor.getString(COLUMN_DOCUMENT_ID)!!,
|
||||
documentId = documentId,
|
||||
icon = getIcon(context, authority, rootId, cursor.getInt(COLUMN_ICON)),
|
||||
title = cursor.getString(COLUMN_TITLE)!!,
|
||||
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
|
||||
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),
|
||||
availableBytes = null,
|
||||
isUsb = true,
|
||||
requiresNetwork = false,
|
||||
enabled = false
|
||||
)
|
||||
roots.add(root)
|
||||
|
@ -216,6 +221,7 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
|
|||
summary = context.getString(summaryRes),
|
||||
availableBytes = null,
|
||||
isUsb = false,
|
||||
requiresNetwork = true,
|
||||
enabled = !isInstalled || isRestore,
|
||||
overrideClickListener = {
|
||||
if (isInstalled) context.startActivity(intent)
|
||||
|
|
|
@ -101,7 +101,7 @@ internal abstract class StorageViewModel(
|
|||
} else {
|
||||
root.title
|
||||
}
|
||||
val storage = Storage(uri, name, root.isUsb)
|
||||
val storage = Storage(uri, name, root.isUsb, root.requiresNetwork)
|
||||
settingsManager.setStorage(storage)
|
||||
|
||||
if (storage.isUsb) {
|
||||
|
|
|
@ -63,7 +63,7 @@ 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(), false)
|
||||
private val storage = Storage(Uri.EMPTY, getRandomString(), false, false)
|
||||
|
||||
@Test
|
||||
fun `starting a new restore set works as expected`() = runBlocking {
|
||||
|
|
Loading…
Reference in a new issue