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 ## Permissions
* `android.permission.BACKUP` to back up application data. * `android.permission.BACKUP` to back up application data.
* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots. * `android.permission.ACCESS_NETWORK_STATE` to check if there is internet access when network storage is used.
* `android.permission.MANAGE_USB` to access the serial number of USB mass storage devices. * `android.permission.MANAGE_USB` to access the serial number of USB mass storage devices.
* `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings and enable call log backup. * `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings and enable call log backup.
* `android.permission.QUERY_ALL_PACKAGES` to get information about all installed apps for backup. * `android.permission.QUERY_ALL_PACKAGES` to get information about all installed apps for backup.
* `android.permission.INSTALL_PACKAGES` to re-install apps when restoring from 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 ## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/stevesoltys/seedvault. 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" android:name="android.permission.BACKUP"
tools:ignore="ProtectedPermissions" /> tools:ignore="ProtectedPermissions" />
<!-- This is needed to check for internet access when backup is stored on network storage -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- This is needed to retrieve the available storage roots --> <!-- This is needed to retrieve the available storage roots -->
<uses-permission <uses-permission
android:name="android.permission.MANAGE_DOCUMENTS" android:name="android.permission.MANAGE_DOCUMENTS"

View file

@ -177,9 +177,17 @@ internal suspend fun DocumentFile.createOrGetFile(
name: String, name: String,
mimeType: String = MIME_TYPE mimeType: String = MIME_TYPE
): DocumentFile { ): DocumentFile {
return findFileBlocking(context, name) ?: createFile(mimeType, name)?.apply { return try {
check(this.name == name) { "File named ${this.name}, but should be $name" } findFileBlocking(context, name) ?: createFile(mimeType, name)?.apply {
} ?: throw IOException() 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) @Throws(IOException::class)
suspend fun DocumentFile.createOrGetDirectory(context: Context, name: String): DocumentFile { suspend fun DocumentFile.createOrGetDirectory(context: Context, name: String): DocumentFile {
return findFileBlocking(context, name) ?: createDirectory(name)?.apply { 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() } ?: throw IOException()
} }

View file

@ -2,36 +2,26 @@ package com.stevesoltys.seedvault.settings
import android.content.ContentResolver import android.content.ContentResolver
import android.provider.Settings import android.provider.Settings
import java.util.concurrent.TimeUnit.DAYS
private val SETTING = Settings.Secure.BACKUP_MANAGER_CONSTANTS 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 { object BackupManagerSettings {
/** /**
* This clears the backup settings, so that default values will be used. * 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) { fun resetDefaults(resolver: ContentResolver) {
// setting this to null will cause the BackupManagerConstants to use default values if (Settings.Secure.getString(resolver, SETTING) != null) {
setSettingValue(resolver, null) // setting this to null will cause the BackupManagerConstants to use default values
} Settings.Secure.putString(resolver, SETTING, 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)
} }
} }

View file

@ -28,10 +28,11 @@ class SettingsActivity : RequireProvisioningActivity(), OnPreferenceStartFragmen
supportActionBar!!.setDisplayHomeAsUpEnabled(true) 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) { if (intent?.action == ACTION_APP_STATUS_LIST) {
showFragment(AppStatusFragment()) showFragment(AppStatusFragment(), true)
} else if (savedInstanceState == null) {
showFragment(SettingsFragment())
} }
} }

View file

@ -1,13 +1,8 @@
package com.stevesoltys.seedvault.settings package com.stevesoltys.seedvault.settings
import android.app.backup.IBackupManager import android.app.backup.IBackupManager
import android.content.Context
import android.content.Context.BACKUP_SERVICE // ktlint-disable no-unused-imports import android.content.Context.BACKUP_SERVICE // ktlint-disable no-unused-imports
import android.content.Intent 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.Bundle
import android.os.RemoteException import android.os.RemoteException
import android.provider.Settings import android.provider.Settings
@ -24,8 +19,6 @@ import androidx.preference.Preference.OnPreferenceChangeListener
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.TwoStatePreference import androidx.preference.TwoStatePreference
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.UsbMonitor
import com.stevesoltys.seedvault.isMassStorage
import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.restore.RestoreActivity import com.stevesoltys.seedvault.restore.RestoreActivity
import com.stevesoltys.seedvault.ui.toRelativeTime import com.stevesoltys.seedvault.ui.toRelativeTime
@ -50,22 +43,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
private var menuRestore: MenuItem? = null private var menuRestore: MenuItem? = null
private var storage: Storage? = 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?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
permitDiskReads { permitDiskReads {
@ -145,14 +122,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
setBackupEnabledState() setBackupEnabledState()
setBackupLocationSummary() setBackupLocationSummary()
setAutoRestoreState() 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) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -163,7 +132,10 @@ class SettingsFragment : PreferenceFragmentCompat() {
if (resources.getBoolean(R.bool.show_restore_in_settings)) { if (resources.getBoolean(R.bool.show_restore_in_settings)) {
menuRestore?.isVisible = true 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) { 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) 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.content.Context
import android.hardware.usb.UsbDevice import android.hardware.usb.UsbDevice
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import java.util.concurrent.ConcurrentSkipListSet import java.util.concurrent.ConcurrentSkipListSet
internal const val PREF_KEY_TOKEN = "token" internal const val PREF_KEY_TOKEN = "token"
internal const val PREF_KEY_BACKUP_APK = "backup_apk" 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_URI = "storageUri"
private const val PREF_KEY_STORAGE_NAME = "storageName" private const val PREF_KEY_STORAGE_NAME = "storageName"
private const val PREF_KEY_STORAGE_IS_USB = "storageIsUsb" private const val PREF_KEY_STORAGE_IS_USB = "storageIsUsb"
private const val PREF_KEY_STORAGE_REQUIRES_NETWORK = "storageRequiresNetwork"
private const val PREF_KEY_FLASH_DRIVE_NAME = "flashDriveName" private const val PREF_KEY_FLASH_DRIVE_NAME = "flashDriveName"
private const val PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER = "flashSerialNumber" private const val PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER = "flashSerialNumber"
@ -24,7 +30,7 @@ private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId"
private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist" private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist"
class SettingsManager(context: Context) { class SettingsManager(private val context: Context) {
private val prefs = permitDiskReads { private val prefs = permitDiskReads {
PreferenceManager.getDefaultSharedPreferences(context) PreferenceManager.getDefaultSharedPreferences(context)
@ -63,6 +69,7 @@ class SettingsManager(context: Context) {
.putString(PREF_KEY_STORAGE_URI, storage.uri.toString()) .putString(PREF_KEY_STORAGE_URI, storage.uri.toString())
.putString(PREF_KEY_STORAGE_NAME, storage.name) .putString(PREF_KEY_STORAGE_NAME, storage.name)
.putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb) .putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb)
.putBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, storage.requiresNetwork)
.apply() .apply()
} }
@ -72,7 +79,8 @@ class SettingsManager(context: Context) {
val name = prefs.getString(PREF_KEY_STORAGE_NAME, null) val name = prefs.getString(PREF_KEY_STORAGE_NAME, null)
?: throw IllegalStateException("no storage name") ?: throw IllegalStateException("no storage name")
val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false) val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false)
return Storage(uri, name, isUsb) val requiresNetwork = prefs.getBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, false)
return Storage(uri, name, isUsb, requiresNetwork)
} }
fun setFlashDrive(usb: FlashDrive?) { fun setFlashDrive(usb: FlashDrive?) {
@ -101,6 +109,29 @@ class SettingsManager(context: Context) {
return FlashDrive(name, serialNumber, vendorId, productId) 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 { fun backupApks(): Boolean {
return prefs.getBoolean(PREF_KEY_BACKUP_APK, true) return prefs.getBoolean(PREF_KEY_BACKUP_APK, true)
} }
@ -119,10 +150,35 @@ class SettingsManager(context: Context) {
data class Storage( data class Storage(
val uri: Uri, val uri: Uri,
val name: String, val name: String,
val isUsb: Boolean val isUsb: Boolean,
val requiresNetwork: Boolean
) { ) {
fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, uri) fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, uri)
?: throw AssertionError("Should only happen on API < 21.") ?: throw AssertionError("Should only happen on API < 21.")
/**
* 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( data class FlashDrive(

View file

@ -2,6 +2,12 @@ package com.stevesoltys.seedvault.settings
import android.app.Application import android.app.Application
import android.content.pm.PackageManager.NameNotFoundException 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.provider.Settings
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
@ -54,8 +60,14 @@ internal class SettingsViewModel(
private val packageService: PackageService private val packageService: PackageService
) : RequireProvisioningViewModel(app, settingsManager, keyManager) { ) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
private val contentResolver = app.contentResolver
private val connectivityManager = app.getSystemService(ConnectivityManager::class.java)
override val isRestoreOperation = false override val isRestoreOperation = false
private val mBackupPossible = MutableLiveData<Boolean>(false)
val backupPossible: LiveData<Boolean> = mBackupPossible
internal val lastBackupTime = metadataManager.lastBackupTime internal val lastBackupTime = metadataManager.lastBackupTime
private val mAppStatusList = switchMap(lastBackupTime) { private val mAppStatusList = switchMap(lastBackupTime) {
@ -67,6 +79,26 @@ internal class SettingsViewModel(
private val mAppEditMode = MutableLiveData<Boolean>() private val mAppEditMode = MutableLiveData<Boolean>()
internal val appEditMode: LiveData<Boolean> = mAppEditMode 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 { init {
val scope = permitDiskReads { val scope = permitDiskReads {
// this shouldn't cause disk reads, but it still does // this shouldn't cause disk reads, but it still does
@ -76,9 +108,44 @@ internal class SettingsViewModel(
// ensures the lastBackupTime LiveData gets set // ensures the lastBackupTime LiveData gets set
metadataManager.getLastBackupTime() 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() { internal fun backupNow() {
// maybe replace the check below with one that checks if our transport service is running
if (notificationManager.hasActiveBackupNotifications()) { if (notificationManager.hasActiveBackupNotifications()) {
Toast.makeText(app, R.string.notification_backup_already_running, LENGTH_LONG).show() Toast.makeText(app, R.string.notification_backup_already_running, LENGTH_LONG).show()
} else { } else {

View file

@ -32,9 +32,27 @@ import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit.DAYS import java.util.concurrent.TimeUnit.DAYS
import java.util.concurrent.TimeUnit.HOURS
private val TAG = BackupCoordinator::class.java.simpleName private val TAG = BackupCoordinator::class.java.simpleName
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 Steve Soltys
* @author Torsten Grote * @author Torsten Grote
@ -54,9 +72,12 @@ internal class BackupCoordinator(
private val nm: BackupNotificationManager private val nm: BackupNotificationManager
) { ) {
private var calledInitialize = false private val state = CoordinatorState(
private var calledClearBackupData = false calledInitialize = false,
private var cancelReason: PackageState = UNKNOWN_ERROR calledClearBackupData = false,
skippedPmBackup = false,
cancelReason = UNKNOWN_ERROR
)
// ------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------
// Transport initialization and quota // Transport initialization and quota
@ -107,12 +128,12 @@ internal class BackupCoordinator(
} }
// [finishBackup] will only be called when we return [TRANSPORT_OK] here // [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully // so we remember that we initialized successfully
calledInitialize = true state.calledInitialize = true
TRANSPORT_OK TRANSPORT_OK
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error initializing device", e) Log.e(TAG, "Error initializing device", e)
// Show error notification if we were ready for backups // Show error notification if we were ready for backups
if (getBackupBackoff() == 0L) nm.onBackupError() if (settingsManager.canDoBackupNow()) nm.onBackupError()
TRANSPORT_ERROR TRANSPORT_ERROR
} }
@ -210,13 +231,29 @@ internal class BackupCoordinator(
data: ParcelFileDescriptor, data: ParcelFileDescriptor,
flags: Int flags: Int
): Int { ): Int {
cancelReason = UNKNOWN_ERROR state.cancelReason = UNKNOWN_ERROR
val packageName = packageInfo.packageName 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) { if (packageName == MAGIC_PACKAGE_MANAGER) {
// backups of package manager metadata do not respect backoff if (!settingsManager.canDoBackupNow()) {
// we need to reject them manually when now is not a good time for a backup // Returning anything else here (except non-incremental-required which re-tries)
if (getBackupBackoff() != 0L) { // will make the system consider the backup state compromised
return TRANSPORT_PACKAGE_REJECTED // 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) val result = kv.performBackup(packageInfo, data, flags)
@ -250,8 +287,8 @@ internal class BackupCoordinator(
fun checkFullBackupSize(size: Long): Int { fun checkFullBackupSize(size: Long): Int {
val result = full.checkFullBackupSize(size) val result = full.checkFullBackupSize(size)
if (result == TRANSPORT_PACKAGE_REJECTED) cancelReason = NO_DATA if (result == TRANSPORT_PACKAGE_REJECTED) state.cancelReason = NO_DATA
else if (result == TRANSPORT_QUOTA_EXCEEDED) cancelReason = QUOTA_EXCEEDED else if (result == TRANSPORT_QUOTA_EXCEEDED) state.cancelReason = QUOTA_EXCEEDED
return result return result
} }
@ -260,7 +297,7 @@ internal class BackupCoordinator(
fileDescriptor: ParcelFileDescriptor, fileDescriptor: ParcelFileDescriptor,
flags: Int flags: Int
): Int { ): Int {
cancelReason = UNKNOWN_ERROR state.cancelReason = UNKNOWN_ERROR
return full.performFullBackup(targetPackage, fileDescriptor, flags) return full.performFullBackup(targetPackage, fileDescriptor, flags)
} }
@ -282,7 +319,10 @@ internal class BackupCoordinator(
suspend fun cancelFullBackup() { suspend fun cancelFullBackup() {
val packageInfo = full.getCurrentPackage() val packageInfo = full.getCurrentPackage()
?: throw AssertionError("Cancelling full backup, but no current package") ?: 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) onPackageBackupError(packageInfo)
full.cancelFullBackup() full.cancelFullBackup()
} }
@ -313,13 +353,13 @@ internal class BackupCoordinator(
Log.w(TAG, "Error clearing full backup data for $packageName", e) Log.w(TAG, "Error clearing full backup data for $packageName", e)
return TRANSPORT_ERROR return TRANSPORT_ERROR
} }
calledClearBackupData = true state.calledClearBackupData = true
return TRANSPORT_OK return TRANSPORT_OK
} }
/** /**
* Finish sending application data to the backup destination. * Finish sending application data to the backup destination. This must be called
* This must be called after [performIncrementalBackup], [performFullBackup], or [clearBackupData] * after [performIncrementalBackup], [performFullBackup], or [clearBackupData]
* to ensure that all data is sent and the operation properly finalized. * 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. * 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 onPackageBackedUp(full.getCurrentPackage()!!) // not-null because we have state
full.finishBackup() full.finishBackup()
} }
calledInitialize || calledClearBackupData -> { state.expectFinish -> {
calledInitialize = false state.onFinish()
calledClearBackupData = false
TRANSPORT_OK TRANSPORT_OK
} }
else -> throw IllegalStateException("Unexpected state in finishBackup()") else -> throw IllegalStateException("Unexpected state in finishBackup()")
@ -405,23 +444,24 @@ internal class BackupCoordinator(
} }
private suspend fun onPackageBackedUp(packageInfo: PackageInfo) { private suspend fun onPackageBackedUp(packageInfo: PackageInfo) {
val packageName = packageInfo.packageName
try { try {
plugin.getMetadataOutputStream().use { plugin.getMetadataOutputStream().use {
metadataManager.onPackageBackedUp(packageInfo, it) metadataManager.onPackageBackedUp(packageInfo, it)
} }
} catch (e: IOException) { } 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) { private suspend fun onPackageBackupError(packageInfo: PackageInfo) {
// don't bother with system apps that have no data // 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 val packageName = packageInfo.packageName
try { try {
plugin.getMetadataOutputStream().use { plugin.getMetadataOutputStream().use {
metadataManager.onPackageBackupError(packageInfo, cancelReason, it) metadataManager.onPackageBackupError(packageInfo, state.cancelReason, it)
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error while writing metadata for $packageName", e) Log.e(TAG, "Error while writing metadata for $packageName", e)
@ -429,15 +469,18 @@ internal class BackupCoordinator(
} }
private fun getBackupBackoff(): Long { private fun getBackupBackoff(): Long {
val noBackoff = 0L val longBackoff = DAYS.toMillis(30)
val defaultBackoff = DAYS.toMillis(30)
// back off if there's no storage set // back off if there's no storage set
val storage = settingsManager.getStorage() ?: return defaultBackoff val storage = settingsManager.getStorage() ?: return longBackoff
// don't back off if storage is not ejectable or available right now return when {
return if (!storage.isUsb || storage.getDocumentFile(context).isDirectory) noBackoff // back off if storage is removable and not available right now
// otherwise back off storage.isUnavailableUsb(context) -> longBackoff
else defaultBackoff // 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) this.state = KVBackupState(packageInfo)
// no need for backup when no data has changed // 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 // check if we have existing data for the given package
val hasDataForPackage = try { 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 // TODO this is plugin specific, needs to be factored out when supporting different plugins
private fun isStorageRemovableAndNotAvailable(): Boolean { private fun isStorageRemovableAndNotAvailable(): Boolean {
val storage = settingsManager.getStorage() ?: return false 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()) { if (!getViewModel().validLocationIsSet()) {
finishAfterTransition() finishAfterTransition()
} }
} } else getViewModel().onStorageLocationChanged()
} }
protected val isSetupWizard: Boolean protected val isSetupWizard: Boolean

View file

@ -22,4 +22,8 @@ abstract class RequireProvisioningViewModel(
internal fun recoveryCodeIsSet() = keyManager.hasBackupKey() 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 { fun hasActiveBackupNotifications(): Boolean {
nm.activeNotifications.forEach { nm.activeNotifications.forEach {
if (it.packageName == context.packageName && if (it.packageName == context.packageName) {
(it.id == NOTIFICATION_ID_OBSERVER || it.id == NOTIFICATION_ID_BACKGROUND) if (it.id == NOTIFICATION_ID_BACKGROUND) return true
) return true if (it.id == NOTIFICATION_ID_OBSERVER) return it.isOngoing
}
} }
return false return false
} }

View file

@ -1,7 +1,16 @@
package com.stevesoltys.seedvault.ui.storage 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.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
@ -17,6 +26,24 @@ class StorageActivity : BackupActivity() {
private lateinit var viewModel: StorageViewModel 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 @CallSuper
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -47,7 +74,11 @@ class StorageActivity : BackupActivity() {
}) })
if (savedInstanceState == null) { 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) { private fun onInvalidLocation(errorMsg: String) {
if (viewModel.isRestoreOperation) { if (viewModel.isRestoreOperation) {
supportFragmentManager.popBackStack() val dialog = AlertDialog.Builder(this)
AlertDialog.Builder(this)
.setTitle(getString(R.string.restore_invalid_location_title)) .setTitle(getString(R.string.restore_invalid_location_title))
.setMessage(errorMsg) .setMessage(errorMsg)
.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } .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 { } else {
// just show error message, if this isn't restore
showFragment(StorageCheckFragment.newInstance(getCheckFragmentTitle(), errorMsg)) showFragment(StorageCheckFragment.newInstance(getCheckFragmentTitle(), errorMsg))
} }
} }
@ -80,6 +126,10 @@ class StorageActivity : BackupActivity() {
return intent?.getBooleanExtra(INTENT_EXTRA_IS_SETUP_WIZARD, false) ?: false 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) { private fun getCheckFragmentTitle() = if (viewModel.isRestoreOperation) {
getString(R.string.storage_check_fragment_restore_title) getString(R.string.storage_check_fragment_restore_title)
} else { } 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.PackageManager.GET_META_DATA
import android.content.pm.ProviderInfo import android.content.pm.ProviderInfo
import android.database.ContentObserver import android.database.ContentObserver
import android.database.Cursor
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.DocumentsContract.PROVIDER_INTERFACE 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 android.util.Log
import com.stevesoltys.seedvault.R 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 private val TAG = StorageRootFetcher::class.java.simpleName
@ -50,6 +39,7 @@ data class StorageRoot(
internal val summary: String?, internal val summary: String?,
internal val availableBytes: Long?, internal val availableBytes: Long?,
internal val isUsb: Boolean, internal val isUsb: Boolean,
internal val requiresNetwork: Boolean,
internal val enabled: Boolean = true, internal val enabled: Boolean = true,
internal val overrideClickListener: (() -> Unit)? = null internal val overrideClickListener: (() -> Unit)? = null
) { ) {
@ -114,49 +104,12 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
private fun getRoots(providerInfo: ProviderInfo): List<StorageRoot> { private fun getRoots(providerInfo: ProviderInfo): List<StorageRoot> {
val authority = providerInfo.authority val authority = providerInfo.authority
val provider = packageManager.resolveContentProvider(authority, GET_META_DATA) 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") 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>) { 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), summary = context.getString(R.string.storage_fake_drive_summary),
availableBytes = null, availableBytes = null,
isUsb = true, isUsb = true,
requiresNetwork = false,
enabled = false enabled = false
) )
roots.add(root) roots.add(root)
@ -216,6 +170,7 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
summary = context.getString(summaryRes), summary = context.getString(summaryRes),
availableBytes = null, availableBytes = null,
isUsb = false, isUsb = false,
requiresNetwork = true,
enabled = !isInstalled || isRestore, enabled = !isInstalled || isRestore,
overrideClickListener = { overrideClickListener = {
if (isInstalled) context.startActivity(intent) if (isInstalled) context.startActivity(intent)
@ -250,55 +205,9 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
} }
private fun isAuthoritySupported(authority: String): Boolean { 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) 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 package com.stevesoltys.seedvault.ui.storage
import android.Manifest.permission.MANAGE_DOCUMENTS
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -17,6 +18,7 @@ import android.widget.ImageView
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
import androidx.annotation.RequiresPermission
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -27,6 +29,7 @@ import org.koin.androidx.viewmodel.ext.android.getSharedViewModel
internal class StorageRootsFragment : Fragment(), StorageRootClickedListener { internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
companion object { companion object {
@RequiresPermission(MANAGE_DOCUMENTS)
fun newInstance(isRestore: Boolean): StorageRootsFragment { fun newInstance(isRestore: Boolean): StorageRootsFragment {
val f = StorageRootsFragment() val f = StorageRootsFragment()
f.arguments = Bundle().apply { f.arguments = Bundle().apply {

View file

@ -101,7 +101,7 @@ internal abstract class StorageViewModel(
} else { } else {
root.title root.title
} }
val storage = Storage(uri, name, root.isUsb) val storage = Storage(uri, name, root.isUsb, root.requiresNetwork)
settingsManager.setStorage(storage) settingsManager.setStorage(storage)
if (storage.isUsb) { if (storage.isUsb) {
@ -109,11 +109,10 @@ internal abstract class StorageViewModel(
val wasSaved = saveUsbDevice() val wasSaved = saveUsbDevice()
// reset stored flash drive, if we did not update it // reset stored flash drive, if we did not update it
if (!wasSaved) settingsManager.setFlashDrive(null) if (!wasSaved) settingsManager.setFlashDrive(null)
BackupManagerSettings.disableAutomaticBackups(app.contentResolver)
} else { } else {
settingsManager.setFlashDrive(null) settingsManager.setFlashDrive(null)
BackupManagerSettings.enableAutomaticBackups(app.contentResolver)
} }
BackupManagerSettings.resetDefaults(app.contentResolver)
Log.d(TAG, "New storage location saved: $uri") Log.d(TAG, "New storage location saved: $uri")

View file

@ -1,6 +1,8 @@
package com.stevesoltys.seedvault.transport.backup 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_ERROR
import android.app.backup.BackupTransport.TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED
import android.app.backup.BackupTransport.TRANSPORT_OK import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED 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.content.pm.PackageInfo
import android.net.Uri import android.net.Uri
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.coAssertThrows import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
@ -63,7 +64,12 @@ internal class BackupCoordinatorTest : BackupTest() {
private val metadataOutputStream = mockk<OutputStream>() private val metadataOutputStream = mockk<OutputStream>()
private val fileDescriptor: ParcelFileDescriptor = mockk() private val fileDescriptor: ParcelFileDescriptor = mockk()
private val packageMetadata: PackageMetadata = mockk() private val packageMetadata: PackageMetadata = mockk()
private val storage = Storage(Uri.EMPTY, getRandomString(), false) private val storage = Storage(
uri = Uri.EMPTY,
name = getRandomString(),
isUsb = false,
requiresNetwork = false
)
@Test @Test
fun `starting a new restore set works as expected`() = runBlocking { 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 { fun `error notification when device initialization fails`() = runBlocking {
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } throws IOException() coEvery { plugin.initializeDevice() } throws IOException()
every { settingsManager.getStorage() } returns storage every { settingsManager.canDoBackupNow() } returns true
every { notificationManager.onBackupError() } just Runs every { notificationManager.onBackupError() } just Runs
assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
@ -118,17 +124,11 @@ internal class BackupCoordinatorTest : BackupTest() {
} }
@Test @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 { runBlocking {
val storage = mockk<Storage>()
val documentFile = mockk<DocumentFile>()
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } throws IOException() coEvery { plugin.initializeDevice() } throws IOException()
every { settingsManager.getStorage() } returns storage every { settingsManager.canDoBackupNow() } returns false
every { storage.isUsb } returns true
every { storage.getDocumentFile(context) } returns documentFile
every { documentFile.isDirectory } returns false
assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) 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 @Test
fun `getBackupQuota() delegates to right plugin`() = runBlocking { fun `getBackupQuota() delegates to right plugin`() = runBlocking {
val isFullBackup = Random.nextBoolean() val isFullBackup = Random.nextBoolean()
@ -331,7 +353,7 @@ internal class BackupCoordinatorTest : BackupTest() {
) )
val packageMetadata: PackageMetadata = mockk() val packageMetadata: PackageMetadata = mockk()
every { settingsManager.getStorage() } returns storage // to check for removable storage every { settingsManager.canDoBackupNow() } returns true
// do actual @pm@ backup // do actual @pm@ backup
coEvery { kv.performBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK 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 // 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 @Test
fun `package with no new data comes back ok right away`() = runBlocking { 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)) assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_DATA_NOT_CHANGED))
assertTrue(backup.hasState()) assertTrue(backup.hasState())
verify { data.close() }
every { plugin.packageFinished(packageInfo) } just Runs every { plugin.packageFinished(packageInfo) } just Runs
assertEquals(TRANSPORT_OK, backup.finishBackup()) 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.app.backup.RestoreDescription.TYPE_KEY_VALUE
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.coAssertThrows import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.BackupMetadata import com.stevesoltys.seedvault.metadata.BackupMetadata
@ -57,7 +56,6 @@ internal class RestoreCoordinatorTest : TransportTest() {
private val inputStream = mockk<InputStream>() private val inputStream = mockk<InputStream>()
private val storage: Storage = mockk() private val storage: Storage = mockk()
private val documentFile: DocumentFile = mockk()
private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" } private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" }
private val packageInfoArray = arrayOf(packageInfo) private val packageInfoArray = arrayOf(packageInfo)
private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2) private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2)
@ -124,9 +122,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test @Test
fun `startRestore() optimized auto-restore with removed storage shows notification`() { fun `startRestore() optimized auto-restore with removed storage shows notification`() {
every { settingsManager.getStorage() } returns storage every { settingsManager.getStorage() } returns storage
every { storage.isUsb } returns true every { storage.isUnavailableUsb(context) } returns true
every { storage.getDocumentFile(context) } returns documentFile
every { documentFile.isDirectory } returns false
every { metadataManager.getPackageMetadata(packageName) } returns PackageMetadata(42L) every { metadataManager.getPackageMetadata(packageName) } returns PackageMetadata(42L)
every { storage.name } returns storageName every { storage.name } returns storageName
every { every {
@ -149,9 +145,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test @Test
fun `startRestore() optimized auto-restore with available storage shows no notification`() { fun `startRestore() optimized auto-restore with available storage shows no notification`() {
every { settingsManager.getStorage() } returns storage every { settingsManager.getStorage() } returns storage
every { storage.isUsb } returns true every { storage.isUnavailableUsb(context) } returns false
every { storage.getDocumentFile(context) } returns documentFile
every { documentFile.isDirectory } returns true
assertEquals(TRANSPORT_OK, restore.startRestore(token, pmPackageInfoArray)) assertEquals(TRANSPORT_OK, restore.startRestore(token, pmPackageInfoArray))
@ -166,9 +160,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test @Test
fun `startRestore() with removed storage shows no notification`() { fun `startRestore() with removed storage shows no notification`() {
every { settingsManager.getStorage() } returns storage every { settingsManager.getStorage() } returns storage
every { storage.isUsb } returns true every { storage.isUnavailableUsb(context) } returns true
every { storage.getDocumentFile(context) } returns documentFile
every { documentFile.isDirectory } returns false
every { metadataManager.getPackageMetadata(packageName) } returns null every { metadataManager.getPackageMetadata(packageName) } returns null
assertEquals(TRANSPORT_ERROR, restore.startRestore(token, pmPackageInfoArray)) 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"?> <?xml version="1.0" encoding="utf-8"?>
<config> <config>
<backup-transport-whitelisted-service <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> </config>