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,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.
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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.")
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in a new issue