Merge pull request #151 from grote/ui-fixes

Various Fixes
This commit is contained in:
Torsten Grote 2020-10-23 07:53:57 -03:00 committed by GitHub
commit a1b68df923
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 499 additions and 245 deletions

View file

@ -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.

View file

@ -9,6 +9,9 @@
android:name="android.permission.BACKUP"
tools:ignore="ProtectedPermissions" />
<!-- This is needed to check for internet access when backup is stored on network storage -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- This is needed to retrieve the available storage roots -->
<uses-permission
android:name="android.permission.MANAGE_DOCUMENTS"

View file

@ -177,9 +177,17 @@ internal suspend fun DocumentFile.createOrGetFile(
name: String,
mimeType: String = MIME_TYPE
): DocumentFile {
return findFileBlocking(context, name) ?: createFile(mimeType, name)?.apply {
check(this.name == name) { "File named ${this.name}, but should be $name" }
} ?: throw IOException()
return try {
findFileBlocking(context, name) ?: createFile(mimeType, name)?.apply {
if (this.name != name) {
throw IOException("File named ${this.name}, but should be $name")
}
} ?: throw IOException()
} catch (e: IllegalArgumentException) {
// Can be thrown by FileSystemProvider#isChildDocument() when flash drive is not plugged-in
// http://aosp.opersys.com/xref/android-11.0.0_r8/xref/frameworks/base/core/java/com/android/internal/content/FileSystemProvider.java#135
throw IOException(e)
}
}
/**
@ -188,7 +196,9 @@ internal suspend fun DocumentFile.createOrGetFile(
@Throws(IOException::class)
suspend fun DocumentFile.createOrGetDirectory(context: Context, name: String): DocumentFile {
return findFileBlocking(context, name) ?: createDirectory(name)?.apply {
check(this.name == name) { "Directory named ${this.name}, but should be $name" }
if (this.name != name) {
throw IOException("Directory named ${this.name}, but should be $name")
}
} ?: throw IOException()
}

View file

@ -2,36 +2,26 @@ package com.stevesoltys.seedvault.settings
import android.content.ContentResolver
import android.provider.Settings
import java.util.concurrent.TimeUnit.DAYS
private val SETTING = Settings.Secure.BACKUP_MANAGER_CONSTANTS
private const val DELIMITER = ','
private const val KEY_VALUE_BACKUP_INTERVAL_MILLISECONDS = "key_value_backup_interval_milliseconds"
private const val FULL_BACKUP_INTERVAL_MILLISECONDS = "full_backup_interval_milliseconds"
object BackupManagerSettings {
/**
* This clears the backup settings, so that default values will be used.
*
* Before end of 2020 (Android 11) we changed the settings in an attempt
* to prevent automatic backups when flash drives are not plugged in.
* This turned out to not work reliably, so reset to defaults again here.
*
* We can remove this code after the last users can be expected
* to have changed storage at least once with this code deployed.
*/
fun enableAutomaticBackups(resolver: ContentResolver) {
// setting this to null will cause the BackupManagerConstants to use default values
setSettingValue(resolver, null)
}
/**
* This sets the backup intervals to a longer than default value. Currently 30 days
*/
fun disableAutomaticBackups(resolver: ContentResolver) {
val value = DAYS.toMillis(30)
val kv = "$KEY_VALUE_BACKUP_INTERVAL_MILLISECONDS=$value"
val full = "$FULL_BACKUP_INTERVAL_MILLISECONDS=$value"
setSettingValue(resolver, "$kv$DELIMITER$full")
}
private fun setSettingValue(resolver: ContentResolver, value: String?) {
Settings.Secure.putString(resolver, SETTING, value)
fun resetDefaults(resolver: ContentResolver) {
if (Settings.Secure.getString(resolver, SETTING) != null) {
// setting this to null will cause the BackupManagerConstants to use default values
Settings.Secure.putString(resolver, SETTING, null)
}
}
}

View file

@ -28,10 +28,11 @@ class SettingsActivity : RequireProvisioningActivity(), OnPreferenceStartFragmen
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
// always start with settings fragment as a base (when fresh start)
if (savedInstanceState == null) showFragment(SettingsFragment())
// add app status fragment on the stack, if started via intent
if (intent?.action == ACTION_APP_STATUS_LIST) {
showFragment(AppStatusFragment())
} else if (savedInstanceState == null) {
showFragment(SettingsFragment())
showFragment(AppStatusFragment(), true)
}
}

View file

@ -1,13 +1,8 @@
package com.stevesoltys.seedvault.settings
import android.app.backup.IBackupManager
import android.content.Context
import android.content.Context.BACKUP_SERVICE // ktlint-disable no-unused-imports
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED
import android.hardware.usb.UsbManager.ACTION_USB_DEVICE_DETACHED
import android.os.Bundle
import android.os.RemoteException
import android.provider.Settings
@ -24,8 +19,6 @@ import androidx.preference.Preference.OnPreferenceChangeListener
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.TwoStatePreference
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.UsbMonitor
import com.stevesoltys.seedvault.isMassStorage
import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.restore.RestoreActivity
import com.stevesoltys.seedvault.ui.toRelativeTime
@ -50,22 +43,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
private var menuRestore: MenuItem? = null
private var storage: Storage? = null
private val usbFilter = IntentFilter(ACTION_USB_DEVICE_ATTACHED).apply {
addAction(ACTION_USB_DEVICE_DETACHED)
}
private val usbReceiver = object : UsbMonitor() {
override fun shouldMonitorStatus(
context: Context,
action: String,
device: UsbDevice
): Boolean {
return device.isMassStorage()
}
override fun onStatusChanged(context: Context, action: String, device: UsbDevice) {
setMenuItemStates()
}
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
permitDiskReads {
@ -145,14 +122,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
setBackupEnabledState()
setBackupLocationSummary()
setAutoRestoreState()
setMenuItemStates()
if (storage?.isUsb == true) context?.registerReceiver(usbReceiver, usbFilter)
}
override fun onStop() {
super.onStop()
if (storage?.isUsb == true) context?.unregisterReceiver(usbReceiver)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -163,7 +132,10 @@ class SettingsFragment : PreferenceFragmentCompat() {
if (resources.getBoolean(R.bool.show_restore_in_settings)) {
menuRestore?.isVisible = true
}
setMenuItemStates()
viewModel.backupPossible.observe(viewLifecycleOwner, Observer { possible ->
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
}
}
}

View file

@ -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(

View file

@ -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<Boolean>(false)
val backupPossible: LiveData<Boolean> = mBackupPossible
internal val lastBackupTime = metadataManager.lastBackupTime
private val mAppStatusList = switchMap(lastBackupTime) {
@ -67,6 +79,26 @@ internal class SettingsViewModel(
private val mAppEditMode = MutableLiveData<Boolean>()
internal val appEditMode: LiveData<Boolean> = mAppEditMode
private val storageObserver = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uris: MutableCollection<Uri>, 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 {

View file

@ -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
}
}
}

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -37,7 +37,7 @@ abstract class RequireProvisioningActivity : BackupActivity() {
if (!getViewModel().validLocationIsSet()) {
finishAfterTransition()
}
}
} else getViewModel().onStorageLocationChanged()
}
protected val isSetupWizard: Boolean

View file

@ -22,4 +22,8 @@ abstract class RequireProvisioningViewModel(
internal fun recoveryCodeIsSet() = keyManager.hasBackupKey()
open fun onStorageLocationChanged() {
// noop can be overwritten by sub-classes
}
}

View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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<StorageRoot> {
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<StorageRoot>()
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<StorageRoot>) {
@ -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
}
}

View file

@ -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<StorageRoot> {
val roots = ArrayList<StorageRoot>()
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
}
}

View file

@ -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 {

View file

@ -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")

View file

@ -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<OutputStream>()
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<Storage>()
val documentFile = mockk<DocumentFile>()
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

View file

@ -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())

View file

@ -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<InputStream>()
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))

11
logcat-verbose.sh Executable file
View file

@ -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

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<config>
<backup-transport-whitelisted-service
service="com.stevesoltys.seedvault/.transport.ConfigurableBackupTransportService"/>
service="com.stevesoltys.seedvault/.transport.ConfigurableBackupTransportService" />
<hidden-api-whitelisted-app package="com.stevesoltys.seedvault" />
</config>