diff --git a/README.md b/README.md
index 2ce6cad4..02d5a7d9 100644
--- a/README.md
+++ b/README.md
@@ -25,11 +25,12 @@ 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.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_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.
* `android.permission.INSTALL_PACKAGES` to re-install apps when restoring from backup.
+* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots (optional) for better UX.
## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/stevesoltys/seedvault.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2cb592db..71c760ca 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" />
+
+
+
+ menuBackupNow?.isEnabled = possible
+ menuRestore?.isEnabled = possible
+ })
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
@@ -218,15 +190,4 @@ class SettingsFragment : PreferenceFragmentCompat() {
backupStatus.summary = getString(R.string.settings_backup_status_summary, lastBackup)
}
- private fun setMenuItemStates() {
- val context = context ?: return
- if (menuBackupNow != null && menuRestore != null) {
- val storage = this.storage
- val enabled = storage != null &&
- (!storage.isUsb || storage.getDocumentFile(context).isDirectory)
- menuBackupNow?.isEnabled = enabled
- menuRestore?.isEnabled = enabled
- }
- }
-
}
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 92c18321..0217638d 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
@@ -2,20 +2,26 @@ package com.stevesoltys.seedvault.settings
import android.content.Context
import android.hardware.usb.UsbDevice
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
import android.net.Uri
import androidx.annotation.UiThread
+import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
+import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import java.util.concurrent.ConcurrentSkipListSet
internal const val PREF_KEY_TOKEN = "token"
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_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"
@@ -24,7 +30,7 @@ private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId"
private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist"
-class SettingsManager(context: Context) {
+class SettingsManager(private val context: Context) {
private val prefs = permitDiskReads {
PreferenceManager.getDefaultSharedPreferences(context)
@@ -63,6 +69,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 +79,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?) {
@@ -101,6 +109,29 @@ class SettingsManager(context: Context) {
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 {
return prefs.getBoolean(PREF_KEY_BACKUP_APK, true)
}
@@ -119,10 +150,35 @@ 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.")
+
+ /**
+ * Returns true if this is USB storage that is not available, false otherwise.
+ *
+ * Must be run off UI thread (ideally I/O).
+ */
+ @WorkerThread
+ fun isUnavailableUsb(context: Context): Boolean {
+ return isUsb && !getDocumentFile(context).isDirectory
+ }
+
+ /**
+ * Returns true if this is storage that requires network access,
+ * but it isn't available right now.
+ */
+ fun isUnavailableNetwork(context: Context): Boolean {
+ return requiresNetwork && !hasInternet(context)
+ }
+
+ private fun hasInternet(context: Context): Boolean {
+ val cm = context.getSystemService(ConnectivityManager::class.java)
+ val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ }
}
data class FlashDrive(
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt
index 5d5f0882..773b7f95 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt
@@ -2,6 +2,12 @@ package com.stevesoltys.seedvault.settings
import android.app.Application
import android.content.pm.PackageManager.NameNotFoundException
+import android.database.ContentObserver
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.net.Uri
import android.provider.Settings
import android.util.Log
import android.widget.Toast
@@ -54,8 +60,14 @@ internal class SettingsViewModel(
private val packageService: PackageService
) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
+ private val contentResolver = app.contentResolver
+ private val connectivityManager = app.getSystemService(ConnectivityManager::class.java)
+
override val isRestoreOperation = false
+ private val mBackupPossible = MutableLiveData(false)
+ val backupPossible: LiveData = mBackupPossible
+
internal val lastBackupTime = metadataManager.lastBackupTime
private val mAppStatusList = switchMap(lastBackupTime) {
@@ -67,6 +79,26 @@ internal class SettingsViewModel(
private val mAppEditMode = MutableLiveData()
internal val appEditMode: LiveData = mAppEditMode
+ private val storageObserver = object : ContentObserver(null) {
+ override fun onChange(selfChange: Boolean, uris: MutableCollection, flags: Int) {
+ onStorageLocationChanged()
+ }
+ }
+
+ private inner class NetworkObserver : ConnectivityManager.NetworkCallback() {
+ var registered = false
+ override fun onAvailable(network: Network) {
+ onStorageLocationChanged()
+ }
+
+ override fun onLost(network: Network) {
+ super.onLost(network)
+ onStorageLocationChanged()
+ }
+ }
+
+ private val networkCallback = NetworkObserver()
+
init {
val scope = permitDiskReads {
// this shouldn't cause disk reads, but it still does
@@ -76,9 +108,44 @@ internal class SettingsViewModel(
// ensures the lastBackupTime LiveData gets set
metadataManager.getLastBackupTime()
}
+ onStorageLocationChanged()
+ }
+
+ override fun onStorageLocationChanged() {
+ val storage = settingsManager.getStorage() ?: return
+
+ // register storage observer
+ contentResolver.unregisterContentObserver(storageObserver)
+ contentResolver.registerContentObserver(storage.uri, false, storageObserver)
+
+ // register network observer if needed
+ if (networkCallback.registered && !storage.requiresNetwork) {
+ connectivityManager.unregisterNetworkCallback(networkCallback)
+ networkCallback.registered = false
+ } else if (!networkCallback.registered && storage.requiresNetwork) {
+ val request = NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .build()
+ connectivityManager.registerNetworkCallback(request, networkCallback)
+ networkCallback.registered = true
+ }
+
+ viewModelScope.launch(Dispatchers.IO) {
+ val canDo = settingsManager.canDoBackupNow()
+ mBackupPossible.postValue(canDo)
+ }
+ }
+
+ override fun onCleared() {
+ contentResolver.unregisterContentObserver(storageObserver)
+ if (networkCallback.registered) {
+ connectivityManager.unregisterNetworkCallback(networkCallback)
+ networkCallback.registered = false
+ }
}
internal fun backupNow() {
+ // maybe replace the check below with one that checks if our transport service is running
if (notificationManager.hasActiveBackupNotifications()) {
Toast.makeText(app, R.string.notification_backup_already_running, LENGTH_LONG).show()
} else {
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 56155918..bf48b8bb 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
@@ -32,9 +32,27 @@ 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
+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 Torsten Grote
@@ -54,9 +72,12 @@ internal class BackupCoordinator(
private val nm: BackupNotificationManager
) {
- private var calledInitialize = false
- private var calledClearBackupData = false
- private var cancelReason: PackageState = UNKNOWN_ERROR
+ private val state = CoordinatorState(
+ calledInitialize = false,
+ calledClearBackupData = false,
+ skippedPmBackup = false,
+ cancelReason = UNKNOWN_ERROR
+ )
// ------------------------------------------------------------------------------------
// Transport initialization and quota
@@ -107,12 +128,12 @@ internal class BackupCoordinator(
}
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully
- calledInitialize = true
+ state.calledInitialize = true
TRANSPORT_OK
} catch (e: IOException) {
Log.e(TAG, "Error initializing device", e)
// Show error notification if we were ready for backups
- if (getBackupBackoff() == 0L) nm.onBackupError()
+ if (settingsManager.canDoBackupNow()) nm.onBackupError()
TRANSPORT_ERROR
}
@@ -210,13 +231,29 @@ internal class BackupCoordinator(
data: ParcelFileDescriptor,
flags: Int
): Int {
- cancelReason = UNKNOWN_ERROR
+ state.cancelReason = UNKNOWN_ERROR
val packageName = packageInfo.packageName
+ // K/V backups (typically starting with package manager metadata - @pm@)
+ // are scheduled with JobInfo.Builder#setOverrideDeadline() and thus do not respect backoff.
+ // We need to reject them manually when we can not do a backup now.
+ // What else we tried can be found in: https://github.com/stevesoltys/seedvault/issues/102
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
+ 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)
@@ -250,8 +287,8 @@ internal class BackupCoordinator(
fun checkFullBackupSize(size: Long): Int {
val result = full.checkFullBackupSize(size)
- if (result == TRANSPORT_PACKAGE_REJECTED) cancelReason = NO_DATA
- else if (result == TRANSPORT_QUOTA_EXCEEDED) cancelReason = QUOTA_EXCEEDED
+ if (result == TRANSPORT_PACKAGE_REJECTED) state.cancelReason = NO_DATA
+ else if (result == TRANSPORT_QUOTA_EXCEEDED) state.cancelReason = QUOTA_EXCEEDED
return result
}
@@ -260,7 +297,7 @@ internal class BackupCoordinator(
fileDescriptor: ParcelFileDescriptor,
flags: Int
): Int {
- cancelReason = UNKNOWN_ERROR
+ state.cancelReason = UNKNOWN_ERROR
return full.performFullBackup(targetPackage, fileDescriptor, flags)
}
@@ -282,7 +319,10 @@ internal class BackupCoordinator(
suspend fun cancelFullBackup() {
val packageInfo = full.getCurrentPackage()
?: 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)
full.cancelFullBackup()
}
@@ -313,13 +353,13 @@ internal class BackupCoordinator(
Log.w(TAG, "Error clearing full backup data for $packageName", e)
return TRANSPORT_ERROR
}
- calledClearBackupData = true
+ state.calledClearBackupData = true
return TRANSPORT_OK
}
/**
- * Finish sending application data to the backup destination.
- * This must be called after [performIncrementalBackup], [performFullBackup], or [clearBackupData]
+ * Finish sending application data to the backup destination. This must be called
+ * after [performIncrementalBackup], [performFullBackup], or [clearBackupData]
* 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.
*
@@ -340,9 +380,8 @@ internal class BackupCoordinator(
onPackageBackedUp(full.getCurrentPackage()!!) // not-null because we have state
full.finishBackup()
}
- calledInitialize || calledClearBackupData -> {
- calledInitialize = false
- calledClearBackupData = false
+ state.expectFinish -> {
+ state.onFinish()
TRANSPORT_OK
}
else -> throw IllegalStateException("Unexpected state in finishBackup()")
@@ -405,23 +444,24 @@ internal class BackupCoordinator(
}
private suspend fun onPackageBackedUp(packageInfo: PackageInfo) {
- val packageName = packageInfo.packageName
try {
plugin.getMetadataOutputStream().use {
metadataManager.onPackageBackedUp(packageInfo, it)
}
} 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) {
// 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
try {
plugin.getMetadataOutputStream().use {
- metadataManager.onPackageBackupError(packageInfo, cancelReason, it)
+ metadataManager.onPackageBackupError(packageInfo, state.cancelReason, it)
}
} catch (e: IOException) {
Log.e(TAG, "Error while writing metadata for $packageName", e)
@@ -429,15 +469,18 @@ 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
+ return when {
+ // back off if storage is removable and not available right now
+ storage.isUnavailableUsb(context) -> longBackoff
+ // back off if storage is on network, but we have no access
+ storage.isUnavailableNetwork(context) -> HOURS.toMillis(1)
+ // otherwise no back off
+ else -> 0L
+ }
}
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt
index be0734c9..2468f75f 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt
@@ -71,7 +71,10 @@ internal class KVBackup(
this.state = KVBackupState(packageInfo)
// 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
val hasDataForPackage = try {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt
index 94ce9e98..3f4e3382 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt
@@ -283,7 +283,7 @@ internal class RestoreCoordinator(
// TODO this is plugin specific, needs to be factored out when supporting different plugins
private fun isStorageRemovableAndNotAvailable(): Boolean {
val storage = settingsManager.getStorage() ?: return false
- return storage.isUsb && !storage.getDocumentFile(context).isDirectory
+ return storage.isUnavailableUsb(context)
}
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningActivity.kt
index f118aab3..c0a511df 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningActivity.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningActivity.kt
@@ -37,7 +37,7 @@ abstract class RequireProvisioningActivity : BackupActivity() {
if (!getViewModel().validLocationIsSet()) {
finishAfterTransition()
}
- }
+ } else getViewModel().onStorageLocationChanged()
}
protected val isSetupWizard: Boolean
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt
index 759e82b5..50952758 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt
@@ -22,4 +22,8 @@ abstract class RequireProvisioningViewModel(
internal fun recoveryCodeIsSet() = keyManager.hasBackupKey()
+ open fun onStorageLocationChanged() {
+ // noop can be overwritten by sub-classes
+ }
+
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt
index 0352edbf..a38147f4 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt
@@ -207,9 +207,10 @@ internal class BackupNotificationManager(private val context: Context) {
fun hasActiveBackupNotifications(): Boolean {
nm.activeNotifications.forEach {
- if (it.packageName == context.packageName &&
- (it.id == NOTIFICATION_ID_OBSERVER || it.id == NOTIFICATION_ID_BACKGROUND)
- ) return true
+ if (it.packageName == context.packageName) {
+ if (it.id == NOTIFICATION_ID_BACKGROUND) return true
+ if (it.id == NOTIFICATION_ID_OBSERVER) return it.isOngoing
+ }
}
return false
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt
index 10350ea2..873bd7d2 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt
@@ -1,7 +1,16 @@
package com.stevesoltys.seedvault.ui.storage
+import android.Manifest.permission.MANAGE_DOCUMENTS
+import android.content.Context
+import android.content.Intent
+import android.content.Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
+import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
+import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+import android.content.pm.PackageManager.PERMISSION_GRANTED
+import android.net.Uri
import android.os.Bundle
import android.util.Log
+import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
import androidx.annotation.CallSuper
import androidx.appcompat.app.AlertDialog
import com.stevesoltys.seedvault.R
@@ -17,6 +26,24 @@ class StorageActivity : BackupActivity() {
private lateinit var viewModel: StorageViewModel
+ /**
+ * The official way to get a SAF [Uri] which we only use if we don't have the
+ * [MANAGE_DOCUMENTS] permission (via [canUseStorageRootsFragment]).
+ */
+ private val openDocumentTree = registerForActivityResult(OpenPersistableDocumentTree()) { uri ->
+ if (uri != null) {
+ Log.e(TAG, "OpenDocumentTree: $uri")
+ val authority = uri.authority ?: throw AssertionError("No authority in $uri")
+ val storageRoot = StorageRootResolver.getStorageRoots(this, authority).getOrNull(0)
+ if (storageRoot == null) {
+ viewModel.onUriPermissionResultReceived(null)
+ } else {
+ viewModel.onStorageRootChosen(storageRoot)
+ viewModel.onUriPermissionResultReceived(uri)
+ }
+ }
+ }
+
@CallSuper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -47,7 +74,11 @@ class StorageActivity : BackupActivity() {
})
if (savedInstanceState == null) {
- showFragment(StorageRootsFragment.newInstance(isRestore()))
+ if (canUseStorageRootsFragment()) {
+ showFragment(StorageRootsFragment.newInstance(isRestore()))
+ } else {
+ openDocumentTree.launch(null)
+ }
}
}
@@ -61,13 +92,28 @@ class StorageActivity : BackupActivity() {
private fun onInvalidLocation(errorMsg: String) {
if (viewModel.isRestoreOperation) {
- supportFragmentManager.popBackStack()
- AlertDialog.Builder(this)
+ val dialog = AlertDialog.Builder(this)
.setTitle(getString(R.string.restore_invalid_location_title))
.setMessage(errorMsg)
.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() }
- .show()
+ if (canUseStorageRootsFragment()) {
+ // We have permission to use StorageRootsFragment,
+ // so pop the back stack to show it again
+ supportFragmentManager.popBackStack()
+ dialog.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() }
+ } else {
+ // We don't have permission to use StorageRootsFragment,
+ // so give option to choose again or cancel.
+ dialog.setPositiveButton(android.R.string.ok) { _, _ ->
+ openDocumentTree.launch(null)
+ }
+ dialog.setNegativeButton(android.R.string.cancel) { _, _ ->
+ finishAfterTransition()
+ }
+ }
+ dialog.show()
} else {
+ // just show error message, if this isn't restore
showFragment(StorageCheckFragment.newInstance(getCheckFragmentTitle(), errorMsg))
}
}
@@ -80,6 +126,10 @@ class StorageActivity : BackupActivity() {
return intent?.getBooleanExtra(INTENT_EXTRA_IS_SETUP_WIZARD, false) ?: false
}
+ private fun canUseStorageRootsFragment(): Boolean {
+ return checkSelfPermission(MANAGE_DOCUMENTS) == PERMISSION_GRANTED
+ }
+
private fun getCheckFragmentTitle() = if (viewModel.isRestoreOperation) {
getString(R.string.storage_check_fragment_restore_title)
} else {
@@ -87,3 +137,13 @@ class StorageActivity : BackupActivity() {
}
}
+
+private class OpenPersistableDocumentTree : OpenDocumentTree() {
+ override fun createIntent(context: Context, input: Uri?): Intent {
+ return super.createIntent(context, input).apply {
+ val flags = FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
+ FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION
+ addFlags(flags)
+ }
+ }
+}
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 c9815651..643435e8 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
@@ -8,26 +8,15 @@ import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager.GET_META_DATA
import android.content.pm.ProviderInfo
import android.database.ContentObserver
-import android.database.Cursor
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.DocumentsContract
import android.provider.DocumentsContract.PROVIDER_INTERFACE
-import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
-import android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID
-import android.provider.DocumentsContract.Root.COLUMN_FLAGS
-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_REMOVABLE_USB
-import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_CREATE
-import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
import android.util.Log
import com.stevesoltys.seedvault.R
-import java.lang.Long.parseLong
+import com.stevesoltys.seedvault.ui.storage.StorageRootResolver.getIcon
private val TAG = StorageRootFetcher::class.java.simpleName
@@ -50,6 +39,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
) {
@@ -114,49 +104,12 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
private fun getRoots(providerInfo: ProviderInfo): List {
val authority = providerInfo.authority
val provider = packageManager.resolveContentProvider(authority, GET_META_DATA)
- if (provider == null || !provider.isSupported()) {
+ return if (provider == null || !provider.isSupported()) {
Log.w(TAG, "Failed to get provider info for $authority")
- return emptyList()
+ emptyList()
+ } else {
+ StorageRootResolver.getStorageRoots(context, authority)
}
-
- val roots = ArrayList()
- val rootsUri = DocumentsContract.buildRootsUri(authority)
-
- var cursor: Cursor? = null
- try {
- cursor = contentResolver.query(rootsUri, null, null, null, null)
- while (cursor!!.moveToNext()) {
- val root = getStorageRoot(authority, cursor)
- if (root != null) roots.add(root)
- }
- } catch (e: Exception) {
- Log.w(TAG, "Failed to load some roots from $authority", e)
- } finally {
- cursor?.close()
- }
- return roots
- }
-
- private fun getStorageRoot(authority: String, cursor: Cursor): StorageRoot? {
- val flags = cursor.getInt(COLUMN_FLAGS)
- val supportsCreate = flags and FLAG_SUPPORTS_CREATE != 0
- val supportsIsChild = flags and FLAG_SUPPORTS_IS_CHILD != 0
- if (!supportsCreate || !supportsIsChild) return null
- val rootId = cursor.getString(COLUMN_ROOT_ID)!!
- if (authority == AUTHORITY_STORAGE && rootId == ROOT_ID_HOME) return null
- return StorageRoot(
- authority = authority,
- rootId = rootId,
- documentId = cursor.getString(COLUMN_DOCUMENT_ID)!!,
- icon = getIcon(context, authority, rootId, cursor.getInt(COLUMN_ICON)),
- title = cursor.getString(COLUMN_TITLE)!!,
- summary = cursor.getString(COLUMN_SUMMARY),
- availableBytes = cursor.getLong(COLUMN_AVAILABLE_BYTES).let { bytes ->
- // AOSP 11 reports -1 instead of null
- if (bytes == -1L) null else bytes
- },
- isUsb = flags and FLAG_REMOVABLE_USB != 0
- )
}
private fun checkOrAddUsbRoot(roots: ArrayList) {
@@ -175,6 +128,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 +170,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)
@@ -250,55 +205,9 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
}
private fun isAuthoritySupported(authority: String): Boolean {
- // just restrict where to store backups, restoring can be more free for forward compatibility
+ // just restrict where to store backups,
+ // restoring can be more free for forward compatibility
return isRestore || whitelistedAuthorities.contains(authority)
}
- private fun Cursor.getString(columnName: String): String? {
- val index = getColumnIndex(columnName)
- return if (index != -1) getString(index) else null
- }
-
- private fun Cursor.getInt(columnName: String): Int {
- val index = getColumnIndex(columnName)
- return if (index != -1) getInt(index) else 0
- }
-
- private fun Cursor.getLong(columnName: String): Long? {
- val index = getColumnIndex(columnName)
- if (index == -1) return null
- val value = getString(index) ?: return null
- return try {
- parseLong(value)
- } catch (e: NumberFormatException) {
- null
- }
- }
-
- private fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? {
- return getPackageIcon(context, authority, icon) ?: when {
- authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> {
- context.getDrawable(R.drawable.ic_phone_android)
- }
- authority == AUTHORITY_STORAGE && rootId != ROOT_ID_HOME -> {
- context.getDrawable(R.drawable.ic_usb)
- }
- authority == AUTHORITY_NEXTCLOUD -> {
- context.getDrawable(R.drawable.nextcloud)
- }
- else -> null
- }
- }
-
- private fun getPackageIcon(context: Context, authority: String, icon: Int): Drawable? {
- if (icon != 0) {
- val pm = context.packageManager
- val info = pm.resolveContentProvider(authority, 0)
- if (info != null) {
- return pm.getDrawable(info.packageName, icon, info.applicationInfo)
- }
- }
- return null
- }
-
}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt
new file mode 100644
index 00000000..07318440
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt
@@ -0,0 +1,113 @@
+package com.stevesoltys.seedvault.ui.storage
+
+import android.content.Context
+import android.database.Cursor
+import android.graphics.drawable.Drawable
+import android.provider.DocumentsContract
+import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
+import android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID
+import android.provider.DocumentsContract.Root.COLUMN_FLAGS
+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
+import android.util.Log
+import com.stevesoltys.seedvault.R
+
+object StorageRootResolver {
+
+ private val TAG = StorageRootResolver::class.java.simpleName
+
+ fun getStorageRoots(context: Context, authority: String): List {
+ val roots = ArrayList()
+ val rootsUri = DocumentsContract.buildRootsUri(authority)
+
+ try {
+ context.contentResolver.query(rootsUri, null, null, null, null)?.use { cursor ->
+ while (cursor.moveToNext()) {
+ val root = getStorageRoot(context, authority, cursor)
+ if (root != null) roots.add(root)
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to load some roots from $authority", e)
+ }
+ return roots
+ }
+
+ private fun getStorageRoot(context: Context, authority: String, cursor: Cursor): StorageRoot? {
+ val flags = cursor.getInt(COLUMN_FLAGS)
+ val supportsCreate = flags and FLAG_SUPPORTS_CREATE != 0
+ val supportsIsChild = flags and FLAG_SUPPORTS_IS_CHILD != 0
+ 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 = documentId,
+ icon = getIcon(context, authority, rootId, cursor.getInt(COLUMN_ICON)),
+ title = cursor.getString(COLUMN_TITLE)!!,
+ summary = cursor.getString(COLUMN_SUMMARY),
+ availableBytes = cursor.getLong(COLUMN_AVAILABLE_BYTES).let { bytes ->
+ // AOSP 11 reports -1 instead of null
+ if (bytes == -1L) null else bytes
+ },
+ isUsb = flags and FLAG_REMOVABLE_USB != 0,
+ requiresNetwork = flags and FLAG_LOCAL_ONLY == 0 // not local only == requires network
+ )
+ }
+
+ private fun Cursor.getString(columnName: String): String? {
+ val index = getColumnIndex(columnName)
+ return if (index != -1) getString(index) else null
+ }
+
+ private fun Cursor.getInt(columnName: String): Int {
+ val index = getColumnIndex(columnName)
+ return if (index != -1) getInt(index) else 0
+ }
+
+ private fun Cursor.getLong(columnName: String): Long? {
+ val index = getColumnIndex(columnName)
+ if (index == -1) return null
+ val value = getString(index) ?: return null
+ return try {
+ java.lang.Long.parseLong(value)
+ } catch (e: NumberFormatException) {
+ null
+ }
+ }
+
+ fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? {
+ return getPackageIcon(context, authority, icon) ?: when {
+ authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> {
+ context.getDrawable(R.drawable.ic_phone_android)
+ }
+ authority == AUTHORITY_STORAGE && rootId != ROOT_ID_HOME -> {
+ context.getDrawable(R.drawable.ic_usb)
+ }
+ authority == AUTHORITY_NEXTCLOUD -> {
+ context.getDrawable(R.drawable.nextcloud)
+ }
+ else -> null
+ }
+ }
+
+ private fun getPackageIcon(context: Context, authority: String, icon: Int): Drawable? {
+ if (icon != 0) {
+ val pm = context.packageManager
+ val info = pm.resolveContentProvider(authority, 0)
+ if (info != null) {
+ return pm.getDrawable(info.packageName, icon, info.applicationInfo)
+ }
+ }
+ return null
+ }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootsFragment.kt
index 70c79825..ae872ad1 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootsFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootsFragment.kt
@@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.ui.storage
+import android.Manifest.permission.MANAGE_DOCUMENTS
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
@@ -17,6 +18,7 @@ import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
+import androidx.annotation.RequiresPermission
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.RecyclerView
@@ -27,6 +29,7 @@ import org.koin.androidx.viewmodel.ext.android.getSharedViewModel
internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
companion object {
+ @RequiresPermission(MANAGE_DOCUMENTS)
fun newInstance(isRestore: Boolean): StorageRootsFragment {
val f = StorageRootsFragment()
f.arguments = Bundle().apply {
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 72047d7c..0f12b0f5 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 @@ 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) {
@@ -109,11 +109,10 @@ internal abstract class StorageViewModel(
val wasSaved = saveUsbDevice()
// reset stored flash drive, if we did not update it
if (!wasSaved) settingsManager.setFlashDrive(null)
- BackupManagerSettings.disableAutomaticBackups(app.contentResolver)
} else {
settingsManager.setFlashDrive(null)
- BackupManagerSettings.enableAutomaticBackups(app.contentResolver)
}
+ BackupManagerSettings.resetDefaults(app.contentResolver)
Log.d(TAG, "New storage location saved: $uri")
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 03967e66..74960721 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
@@ -1,6 +1,8 @@
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_NON_INCREMENTAL_BACKUP_REQUIRED
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
@@ -9,7 +11,6 @@ import android.content.pm.ApplicationInfo.FLAG_STOPPED
import android.content.pm.PackageInfo
import android.net.Uri
import android.os.ParcelFileDescriptor
-import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomString
@@ -63,7 +64,12 @@ internal class BackupCoordinatorTest : BackupTest() {
private val metadataOutputStream = mockk()
private val fileDescriptor: ParcelFileDescriptor = mockk()
private val packageMetadata: PackageMetadata = mockk()
- private val storage = Storage(Uri.EMPTY, getRandomString(), false)
+ private val storage = Storage(
+ uri = Uri.EMPTY,
+ name = getRandomString(),
+ isUsb = false,
+ requiresNetwork = false
+ )
@Test
fun `starting a new restore set works as expected`() = runBlocking {
@@ -104,7 +110,7 @@ internal class BackupCoordinatorTest : BackupTest() {
fun `error notification when device initialization fails`() = runBlocking {
every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } throws IOException()
- every { settingsManager.getStorage() } returns storage
+ every { settingsManager.canDoBackupNow() } returns true
every { notificationManager.onBackupError() } just Runs
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
@@ -118,17 +124,11 @@ internal class BackupCoordinatorTest : BackupTest() {
}
@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 {
- val storage = mockk()
- val documentFile = mockk()
-
every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } throws IOException()
- every { settingsManager.getStorage() } returns storage
- every { storage.isUsb } returns true
- every { storage.getDocumentFile(context) } returns documentFile
- every { documentFile.isDirectory } returns false
+ every { settingsManager.canDoBackupNow() } returns false
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
@@ -140,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
fun `getBackupQuota() delegates to right plugin`() = runBlocking {
val isFullBackup = Random.nextBoolean()
@@ -331,7 +353,7 @@ internal class BackupCoordinatorTest : BackupTest() {
)
val packageMetadata: PackageMetadata = mockk()
- every { settingsManager.getStorage() } returns storage // to check for removable storage
+ every { settingsManager.canDoBackupNow() } returns true
// do actual @pm@ backup
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
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt
index 367484f2..1a7f8d80 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/KVBackupTest.kt
@@ -153,9 +153,13 @@ internal class KVBackupTest : BackupTest() {
@Test
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))
assertTrue(backup.hasState())
+ verify { data.close() }
+
every { plugin.packageFinished(packageInfo) } just Runs
assertEquals(TRANSPORT_OK, backup.finishBackup())
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt
index 6c3fdcdc..9318f923 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt
@@ -8,7 +8,6 @@ import android.app.backup.RestoreDescription.TYPE_FULL_STREAM
import android.app.backup.RestoreDescription.TYPE_KEY_VALUE
import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor
-import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.BackupMetadata
@@ -57,7 +56,6 @@ internal class RestoreCoordinatorTest : TransportTest() {
private val inputStream = mockk()
private val storage: Storage = mockk()
- private val documentFile: DocumentFile = mockk()
private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" }
private val packageInfoArray = arrayOf(packageInfo)
private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2)
@@ -124,9 +122,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test
fun `startRestore() optimized auto-restore with removed storage shows notification`() {
every { settingsManager.getStorage() } returns storage
- every { storage.isUsb } returns true
- every { storage.getDocumentFile(context) } returns documentFile
- every { documentFile.isDirectory } returns false
+ every { storage.isUnavailableUsb(context) } returns true
every { metadataManager.getPackageMetadata(packageName) } returns PackageMetadata(42L)
every { storage.name } returns storageName
every {
@@ -149,9 +145,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test
fun `startRestore() optimized auto-restore with available storage shows no notification`() {
every { settingsManager.getStorage() } returns storage
- every { storage.isUsb } returns true
- every { storage.getDocumentFile(context) } returns documentFile
- every { documentFile.isDirectory } returns true
+ every { storage.isUnavailableUsb(context) } returns false
assertEquals(TRANSPORT_OK, restore.startRestore(token, pmPackageInfoArray))
@@ -166,9 +160,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test
fun `startRestore() with removed storage shows no notification`() {
every { settingsManager.getStorage() } returns storage
- every { storage.isUsb } returns true
- every { storage.getDocumentFile(context) } returns documentFile
- every { documentFile.isDirectory } returns false
+ every { storage.isUnavailableUsb(context) } returns true
every { metadataManager.getPackageMetadata(packageName) } returns null
assertEquals(TRANSPORT_ERROR, restore.startRestore(token, pmPackageInfoArray))
diff --git a/logcat-verbose.sh b/logcat-verbose.sh
new file mode 100755
index 00000000..0099a2e5
--- /dev/null
+++ b/logcat-verbose.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+
+set -ex
+
+adb shell setprop log.tag.BackupManagerService VERBOSE
+adb shell setprop log.tag.BackupManagerConstants VERBOSE
+adb shell setprop log.tag.BackupTransportManager VERBOSE
+adb shell setprop log.tag.KeyValueBackupJob VERBOSE
+adb shell setprop log.tag.KeyValueBackupTask VERBOSE
+adb shell setprop log.tag.TransportClient VERBOSE
+adb shell setprop log.tag.PMBA VERBOSE
diff --git a/whitelist_com.stevesoltys.seedvault.xml b/whitelist_com.stevesoltys.seedvault.xml
index 64321699..c3c2877b 100644
--- a/whitelist_com.stevesoltys.seedvault.xml
+++ b/whitelist_com.stevesoltys.seedvault.xml
@@ -1,5 +1,6 @@
+ service="com.stevesoltys.seedvault/.transport.ConfigurableBackupTransportService" />
+