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" /> +