commit
a1b68df923
24 changed files with 499 additions and 245 deletions
|
@ -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.
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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
11
logcat-verbose.sh
Executable 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
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue