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.
diff --git a/README.md b/README.md
index 2ce6cad..4dd2ad7 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,8 @@
 
 ## Permissions
 * `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.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.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2cb592d..71c760c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -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"
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
index 92c1832..33ed40b 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
@@ -16,6 +16,7 @@
 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 @@
             .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 @@
         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 @@
 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.")
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
index 5615591..8670f8d 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
@@ -14,6 +14,8 @@
 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.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 @@
     ): 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 @@
 
     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)
     }
 
 }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootFetcher.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootFetcher.kt
index c981565..8e41cc8 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootFetcher.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootFetcher.kt
@@ -22,6 +22,7 @@
 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 @@
     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 @@
         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 @@
                 // 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 @@
             summary = context.getString(R.string.storage_fake_drive_summary),
             availableBytes = null,
             isUsb = true,
+            requiresNetwork = false,
             enabled = false
         )
         roots.add(root)
@@ -216,6 +221,7 @@
             summary = context.getString(summaryRes),
             availableBytes = null,
             isUsb = false,
+            requiresNetwork = true,
             enabled = !isInstalled || isRestore,
             overrideClickListener = {
                 if (isInstalled) context.startActivity(intent)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt
index 72047d7..e5e48b0 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageViewModel.kt
@@ -101,7 +101,7 @@
         } 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) {
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt
index 03967e6..f9d6a54 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt
@@ -63,7 +63,7 @@
     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 {