Refactor StorageRoots to be more generic to prepare for non-SAF storage plugins

and to make it easier to add placeholders for storage options
This commit is contained in:
Torsten Grote 2022-01-10 11:36:56 -03:00 committed by Chirayu Desai
parent b1ca70193c
commit 4ee7605b50
8 changed files with 233 additions and 156 deletions

View file

@ -0,0 +1,123 @@
package com.stevesoltys.seedvault.ui.storage
import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_VIEW
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.net.Uri
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
import com.stevesoltys.seedvault.ui.storage.StorageRootResolver.getIcon
private const val NEXTCLOUD_PACKAGE = "com.nextcloud.client"
private const val NEXTCLOUD_ACTIVITY = "com.owncloud.android.authentication.AuthenticatorActivity"
/**
* A class for storage option placeholders that need to be shown under certain circumstances.
* E.g. a way to install an app when needed for restore.
*/
internal class SafStorageOptions(
private val context: Context,
private val isRestore: Boolean,
private val whitelistedAuthorities: Array<String>,
) {
private val packageManager = context.packageManager
internal fun checkOrAddExtraRoots(roots: ArrayList<SafOption>) {
checkOrAddUsbRoot(roots)
checkOrAddNextCloudRoot(roots)
}
private fun checkOrAddUsbRoot(roots: ArrayList<SafOption>) {
if (doNotInclude(AUTHORITY_STORAGE, roots) { it.isUsb }) return
val root = SafOption(
authority = AUTHORITY_STORAGE,
rootId = "usb",
documentId = "fake",
icon = getIcon(context, AUTHORITY_STORAGE, "usb", 0),
title = context.getString(R.string.storage_fake_drive_title),
summary = context.getString(R.string.storage_fake_drive_summary),
availableBytes = null,
isUsb = true,
requiresNetwork = false,
enabled = false
)
roots.add(root)
}
/**
* This adds a fake Nextcloud entry if no real one was found.
*
* If Nextcloud is *not* installed,
* the user will always have the option to install it by clicking the entry.
*
* If it *is* installed and this is restore, the user can set up a new account by clicking.
* FIXME: If this isn't restore, the entry should be disabled,
* because we don't know if there's just no account or an activated passcode
* (which hides existing accounts).
*/
private fun checkOrAddNextCloudRoot(roots: ArrayList<SafOption>) {
if (doNotInclude(AUTHORITY_NEXTCLOUD, roots)) return
val intent = Intent().apply {
addFlags(FLAG_ACTIVITY_NEW_TASK)
setClassName(NEXTCLOUD_PACKAGE, NEXTCLOUD_ACTIVITY)
// setting a nc:// Uri prevents FirstRunActivity to show
data = Uri.parse("nc://login/server:")
putExtra("onlyAdd", true)
}
val marketIntent =
Intent(ACTION_VIEW, Uri.parse("market://details?id=$NEXTCLOUD_PACKAGE")).apply {
addFlags(FLAG_ACTIVITY_NEW_TASK)
}
val isInstalled = packageManager.resolveActivity(intent, 0) != null
val canInstall = packageManager.resolveActivity(marketIntent, 0) != null
val summaryRes = if (isInstalled) {
if (isRestore) R.string.storage_fake_nextcloud_summary_installed
else R.string.storage_fake_nextcloud_summary_unavailable
} else {
if (canInstall) R.string.storage_fake_nextcloud_summary
else R.string.storage_fake_nextcloud_summary_unavailable_market
}
val root = SafOption(
authority = AUTHORITY_NEXTCLOUD,
rootId = "fake",
documentId = "fake",
icon = getIcon(context, AUTHORITY_NEXTCLOUD, "fake", 0),
title = context.getString(R.string.storage_fake_nextcloud_title),
summary = context.getString(summaryRes),
availableBytes = null,
isUsb = false,
requiresNetwork = true,
enabled = isInstalled || canInstall,
nonDefaultAction = {
if (isInstalled) context.startActivity(intent)
else if (canInstall) context.startActivity(marketIntent)
}
)
roots.add(root)
}
private fun doNotInclude(
authority: String,
roots: ArrayList<SafOption>,
doNotIncludeIfTrue: ((SafOption) -> Boolean)? = null,
): Boolean {
if (!isAuthoritySupported(authority)) return true
for (root in roots) {
if (root.authority == authority && doNotIncludeIfTrue?.invoke(root) != false) {
return true
}
}
return false
}
private fun isAuthoritySupported(authority: String): Boolean {
// just restrict where to store backups,
// restoring can be more free for forward compatibility
return isRestore || whitelistedAuthorities.contains(authority)
}
}

View file

@ -37,7 +37,7 @@ class StorageActivity : BackupActivity() {
if (storageRoot == null) {
viewModel.onUriPermissionResultReceived(null)
} else {
viewModel.onStorageRootChosen(storageRoot)
viewModel.onSafOptionChosen(storageRoot)
viewModel.onUriPermissionResultReceived(uri)
}
}
@ -72,7 +72,7 @@ class StorageActivity : BackupActivity() {
if (savedInstanceState == null) {
if (canUseStorageRootsFragment()) {
showFragment(StorageRootsFragment.newInstance(isRestore()))
showFragment(StorageOptionsFragment.newInstance(isRestore()))
} else {
openDocumentTree.launch(null)
}

View file

@ -0,0 +1,48 @@
package com.stevesoltys.seedvault.ui.storage
import android.graphics.drawable.Drawable
import android.net.Uri
import android.provider.DocumentsContract.buildTreeDocumentUri
internal sealed class StorageOption {
abstract val id: String
abstract val icon: Drawable?
abstract val title: String
abstract val summary: String?
abstract val availableBytes: Long?
abstract val requiresNetwork: Boolean
abstract val enabled: Boolean
abstract val nonDefaultAction: (() -> Unit)?
data class SafOption(
override val icon: Drawable?,
override val title: String,
override val summary: String?,
override val availableBytes: Long?,
val authority: String,
val rootId: String,
val documentId: String,
val isUsb: Boolean,
override val requiresNetwork: Boolean,
override val enabled: Boolean = true,
override val nonDefaultAction: (() -> Unit)? = null,
) : StorageOption() {
override val id: String = "saf-$authority"
val uri: Uri by lazy {
buildTreeDocumentUri(authority, documentId)
}
fun isInternal(): Boolean {
return authority == AUTHORITY_STORAGE && !isUsb
}
}
override fun equals(other: Any?): Boolean {
return other is StorageOption && other.id == id
}
override fun hashCode(): Int {
return id.hashCode()
}
}

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.ui.storage
import android.annotation.SuppressLint
import android.content.Context
import android.text.format.Formatter
import android.view.LayoutInflater
@ -13,40 +14,42 @@ import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.storage.StorageRootAdapter.StorageRootViewHolder
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
import com.stevesoltys.seedvault.ui.storage.StorageOptionAdapter.StorageOptionViewHolder
internal class StorageRootAdapter(
internal class StorageOptionAdapter(
private val isRestore: Boolean,
private val listener: StorageRootClickedListener,
) : Adapter<StorageRootViewHolder>() {
private val listener: StorageOptionClickedListener,
) : Adapter<StorageOptionViewHolder>() {
private val items = ArrayList<StorageRoot>()
private val items = ArrayList<StorageOption>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StorageRootViewHolder {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StorageOptionViewHolder {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_storage_root, parent, false) as View
return StorageRootViewHolder(v)
return StorageOptionViewHolder(v)
}
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: StorageRootViewHolder, position: Int) {
override fun onBindViewHolder(holder: StorageOptionViewHolder, position: Int) {
holder.bind(items[position])
}
internal fun setItems(items: List<StorageRoot>) {
@SuppressLint("NotifyDataSetChanged")
internal fun setItems(items: List<StorageOption>) {
this.items.clear()
this.items.addAll(items)
notifyDataSetChanged()
}
internal inner class StorageRootViewHolder(private val v: View) : ViewHolder(v) {
internal inner class StorageOptionViewHolder(private val v: View) : ViewHolder(v) {
private val iconView = v.findViewById<ImageView>(R.id.iconView)
private val titleView = v.findViewById<TextView>(R.id.titleView)
private val summaryView = v.findViewById<TextView>(R.id.summaryView)
internal fun bind(item: StorageRoot) {
internal fun bind(item: StorageOption) {
if (item.enabled) {
v.isEnabled = true
v.alpha = 1f
@ -63,16 +66,16 @@ internal class StorageRootAdapter(
summaryView.visibility = VISIBLE
}
item.availableBytes != null -> {
val str = Formatter.formatFileSize(v.context, item.availableBytes)
val str = Formatter.formatFileSize(v.context, item.availableBytes!!)
summaryView.text = v.context.getString(R.string.storage_available_bytes, str)
summaryView.visibility = VISIBLE
}
else -> summaryView.visibility = GONE
}
v.setOnClickListener {
if (item.overrideClickListener != null) {
item.overrideClickListener.invoke()
} else if (!isRestore && item.isInternal()) {
if (item.nonDefaultAction != null) {
item.nonDefaultAction?.invoke()
} else if (!isRestore && item is SafOption && item.isInternal()) {
showWarningDialog(v.context, item)
} else {
listener.onClick(item)
@ -82,7 +85,7 @@ internal class StorageRootAdapter(
}
private fun showWarningDialog(context: Context, item: StorageRoot) {
private fun showWarningDialog(context: Context, item: StorageOption) {
AlertDialog.Builder(context)
.setTitle(R.string.storage_internal_warning_title)
.setMessage(R.string.storage_internal_warning_message)

View file

@ -1,8 +1,8 @@
package com.stevesoltys.seedvault.ui.storage
import android.Manifest.permission.MANAGE_DOCUMENTS
import android.app.Activity.RESULT_FIRST_USER
import android.annotation.SuppressLint
import android.app.Activity.RESULT_FIRST_USER
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
@ -24,14 +24,15 @@ import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
import org.koin.androidx.viewmodel.ext.android.getSharedViewModel
internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener {
companion object {
@RequiresPermission(MANAGE_DOCUMENTS)
fun newInstance(isRestore: Boolean): StorageRootsFragment {
val f = StorageRootsFragment()
fun newInstance(isRestore: Boolean): StorageOptionsFragment {
val f = StorageOptionsFragment()
f.arguments = Bundle().apply {
putBoolean(INTENT_EXTRA_IS_RESTORE, isRestore)
}
@ -48,7 +49,7 @@ internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
private lateinit var progressBar: ProgressBar
private lateinit var skipView: TextView
private val adapter by lazy { StorageRootAdapter(viewModel.isRestoreOperation, this) }
private val adapter by lazy { StorageOptionAdapter(viewModel.isRestoreOperation, this) }
override fun onCreateView(
inflater: LayoutInflater,
@ -97,7 +98,7 @@ internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
listView.adapter = adapter
viewModel.storageRoots.observe(viewLifecycleOwner, { roots ->
viewModel.storageOptions.observe(viewLifecycleOwner, { roots ->
onRootsLoaded(roots)
})
}
@ -107,7 +108,7 @@ internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
viewModel.loadStorageRoots()
}
private fun onRootsLoaded(roots: List<StorageRoot>) {
private fun onRootsLoaded(roots: List<StorageOption>) {
progressBar.visibility = INVISIBLE
adapter.setItems(roots)
}
@ -116,15 +117,19 @@ internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
viewModel.onUriPermissionResultReceived(uri)
}
override fun onClick(root: StorageRoot) {
viewModel.onStorageRootChosen(root)
openDocumentTree.launch(root.uri)
override fun onClick(storageOption: StorageOption) {
if (storageOption is SafOption) {
viewModel.onSafOptionChosen(storageOption)
openDocumentTree.launch(storageOption.uri)
} else {
throw IllegalArgumentException("Non-SAF storage not yet supported")
}
}
}
internal interface StorageRootClickedListener {
fun onClick(root: StorageRoot)
internal interface StorageOptionClickedListener {
fun onClick(storageOption: StorageOption)
}
private class OpenSeedvaultTree : OpenDocumentTree() {

View file

@ -3,20 +3,17 @@ package com.stevesoltys.seedvault.ui.storage
import android.Manifest.permission.MANAGE_DOCUMENTS
import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_VIEW
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager.GET_META_DATA
import android.content.pm.ProviderInfo
import android.database.ContentObserver
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.DocumentsContract
import android.provider.DocumentsContract.PROVIDER_INTERFACE
import android.provider.DocumentsContract.buildRootsUri
import android.util.Log
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.storage.StorageRootResolver.getIcon
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
private val TAG = StorageRootFetcher::class.java.simpleName
@ -27,32 +24,6 @@ const val ROOT_ID_HOME = "home"
const val AUTHORITY_DOWNLOADS = "com.android.providers.downloads.documents"
const val AUTHORITY_NEXTCLOUD = "org.nextcloud.documents"
private const val NEXTCLOUD_PACKAGE = "com.nextcloud.client"
private const val NEXTCLOUD_ACTIVITY = "com.owncloud.android.authentication.AuthenticatorActivity"
data class StorageRoot(
internal val authority: String,
internal val rootId: String,
internal val documentId: String,
internal val icon: Drawable?,
internal val title: String,
internal val summary: String?,
internal val availableBytes: Long?,
internal val isUsb: Boolean,
internal val requiresNetwork: Boolean,
internal val enabled: Boolean = true,
internal val overrideClickListener: (() -> Unit)? = null,
) {
internal val uri: Uri by lazy {
DocumentsContract.buildTreeDocumentUri(authority, documentId)
}
fun isInternal(): Boolean {
return authority == AUTHORITY_STORAGE && !isUsb
}
}
internal interface RemovableStorageListener {
fun onStorageChanged()
}
@ -63,6 +34,7 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
private val contentResolver = context.contentResolver
private val whitelistedAuthorities =
context.resources.getStringArray(R.array.storage_authority_whitelist)
private val safStorageOptions = SafStorageOptions(context, isRestore, whitelistedAuthorities)
private var listener: RemovableStorageListener? = null
private val handler = Handler(Looper.getMainLooper())
@ -76,7 +48,7 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
internal fun setRemovableStorageListener(listener: RemovableStorageListener?) {
this.listener = listener
if (listener != null) {
val rootsUri = DocumentsContract.buildRootsUri(AUTHORITY_STORAGE)
val rootsUri = buildRootsUri(AUTHORITY_STORAGE)
contentResolver.registerContentObserver(rootsUri, true, observer)
} else {
contentResolver.unregisterContentObserver(observer)
@ -85,8 +57,8 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
internal fun getRemovableStorageListener() = listener
internal fun getStorageRoots(): List<StorageRoot> {
val roots = ArrayList<StorageRoot>()
internal fun getStorageOptions(): List<StorageOption> {
val roots = ArrayList<SafOption>()
val intent = Intent(PROVIDER_INTERFACE)
val providers = packageManager.queryIntentContentProviders(intent, 0)
for (info in providers) {
@ -96,12 +68,12 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
roots.addAll(getRoots(providerInfo))
}
}
checkOrAddUsbRoot(roots)
checkOrAddNextCloudRoot(roots)
// there's a couple of options, we still want to show, even if no roots are found for them
safStorageOptions.checkOrAddExtraRoots(roots)
return roots
}
private fun getRoots(providerInfo: ProviderInfo): List<StorageRoot> {
private fun getRoots(providerInfo: ProviderInfo): List<SafOption> {
val authority = providerInfo.authority
val provider = packageManager.resolveContentProvider(authority, GET_META_DATA)
return if (provider == null || !provider.isSupported()) {
@ -112,82 +84,6 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
}
}
private fun checkOrAddUsbRoot(roots: ArrayList<StorageRoot>) {
if (!isAuthoritySupported(AUTHORITY_STORAGE)) return
for (root in roots) {
// return if we already have a USB storage root
if (root.authority == AUTHORITY_STORAGE && root.isUsb) return
}
val root = StorageRoot(
authority = AUTHORITY_STORAGE,
rootId = "usb",
documentId = "fake",
icon = getIcon(context, AUTHORITY_STORAGE, "usb", 0),
title = context.getString(R.string.storage_fake_drive_title),
summary = context.getString(R.string.storage_fake_drive_summary),
availableBytes = null,
isUsb = true,
requiresNetwork = false,
enabled = false
)
roots.add(root)
}
/**
* This adds a fake Nextcloud entry if no real one was found.
*
* If Nextcloud is *not* installed,
* the user will always have the option to install it by clicking the entry.
*
* If it *is* installed and this is restore, the user can set up a new account by clicking.
* If this isn't restore, the entry will be disabled,
* because we don't know if there's no account or an activated passcode.
*/
private fun checkOrAddNextCloudRoot(roots: ArrayList<StorageRoot>) {
for (root in roots) {
// return if we already have a NextCloud storage root
if (root.authority == AUTHORITY_NEXTCLOUD) return
}
val intent = Intent().apply {
addFlags(FLAG_ACTIVITY_NEW_TASK)
setClassName(NEXTCLOUD_PACKAGE, NEXTCLOUD_ACTIVITY)
// setting a nc:// Uri prevents FirstRunActivity to show
data = Uri.parse("nc://login/server:")
putExtra("onlyAdd", true)
}
val marketIntent =
Intent(ACTION_VIEW, Uri.parse("market://details?id=$NEXTCLOUD_PACKAGE")).apply {
addFlags(FLAG_ACTIVITY_NEW_TASK)
}
val isInstalled = packageManager.resolveActivity(intent, 0) != null
val canInstall = packageManager.resolveActivity(marketIntent, 0) != null
val summaryRes = if (isInstalled) {
if (isRestore) R.string.storage_fake_nextcloud_summary_installed
else R.string.storage_fake_nextcloud_summary_unavailable
} else {
if (canInstall) R.string.storage_fake_nextcloud_summary
else R.string.storage_fake_nextcloud_summary_unavailable_market
}
val root = StorageRoot(
authority = AUTHORITY_NEXTCLOUD,
rootId = "fake",
documentId = "fake",
icon = getIcon(context, AUTHORITY_NEXTCLOUD, "fake", 0),
title = context.getString(R.string.storage_fake_nextcloud_title),
summary = context.getString(summaryRes),
availableBytes = null,
isUsb = false,
requiresNetwork = true,
enabled = isInstalled || canInstall,
overrideClickListener = {
if (isInstalled) context.startActivity(intent)
else if (canInstall) context.startActivity(marketIntent)
}
)
roots.add(root)
}
private fun ProviderInfo.isSupported(): Boolean {
return if (!exported) {
Log.w(TAG, "Provider is not exported")

View file

@ -17,13 +17,14 @@ import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_CREATE
import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
import android.util.Log
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
object StorageRootResolver {
internal object StorageRootResolver {
private val TAG = StorageRootResolver::class.java.simpleName
fun getStorageRoots(context: Context, authority: String): List<StorageRoot> {
val roots = ArrayList<StorageRoot>()
fun getStorageRoots(context: Context, authority: String): List<SafOption> {
val roots = ArrayList<SafOption>()
val rootsUri = DocumentsContract.buildRootsUri(authority)
try {
@ -39,7 +40,7 @@ object StorageRootResolver {
return roots
}
private fun getStorageRoot(context: Context, authority: String, cursor: Cursor): StorageRoot? {
private fun getStorageRoot(context: Context, authority: String, cursor: Cursor): SafOption? {
val flags = cursor.getInt(COLUMN_FLAGS)
val supportsCreate = flags and FLAG_SUPPORTS_CREATE != 0
val supportsIsChild = flags and FLAG_SUPPORTS_IS_CHILD != 0
@ -47,7 +48,7 @@ object StorageRootResolver {
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(
return SafOption(
authority = authority,
rootId = rootId,
documentId = documentId,

View file

@ -20,6 +20,7 @@ import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.Storage
import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
private val TAG = StorageViewModel::class.java.simpleName
@ -28,8 +29,8 @@ internal abstract class StorageViewModel(
protected val settingsManager: SettingsManager,
) : AndroidViewModel(app), RemovableStorageListener {
private val mStorageRoots = MutableLiveData<List<StorageRoot>>()
internal val storageRoots: LiveData<List<StorageRoot>> get() = mStorageRoots
private val mStorageOptions = MutableLiveData<List<StorageOption>>()
internal val storageOptions: LiveData<List<StorageOption>> get() = mStorageOptions
private val mLocationSet = MutableLiveEvent<Boolean>()
internal val locationSet: LiveEvent<Boolean> get() = mLocationSet
@ -38,7 +39,7 @@ internal abstract class StorageViewModel(
internal val locationChecked: LiveEvent<LocationResult> get() = mLocationChecked
private val storageRootFetcher by lazy { StorageRootFetcher(app, isRestoreOperation) }
private var storageRoot: StorageRoot? = null
private var safOption: SafOption? = null
internal var isSetupWizard: Boolean = false
internal val hasStorageSet: Boolean
@ -63,14 +64,14 @@ internal abstract class StorageViewModel(
storageRootFetcher.setRemovableStorageListener(this)
}
Thread {
mStorageRoots.postValue(storageRootFetcher.getStorageRoots())
mStorageOptions.postValue(storageRootFetcher.getStorageOptions())
}.start()
}
override fun onStorageChanged() = loadStorageRoots()
fun onStorageRootChosen(root: StorageRoot) {
storageRoot = root
fun onSafOptionChosen(option: SafOption) {
safOption = option
}
internal fun onUriPermissionResultReceived(uri: Uri?) {
@ -91,13 +92,13 @@ internal abstract class StorageViewModel(
}
/**
* Saves the storage behind the given [Uri] (and saved [storageRoot]).
* Saves the storage behind the given [Uri] (and saved [safOption]).
*
* @return true if the storage is a USB flash drive, false otherwise.
*/
protected fun saveStorage(uri: Uri): Boolean {
// store backup storage location in settings
val root = storageRoot ?: throw IllegalStateException("no storage root")
val root = safOption ?: throw IllegalStateException("no storage root")
val name = if (root.isInternal()) {
"${root.title} (${app.getString(R.string.settings_backup_location_internal)})"
} else {