From e9f3c082205d72f873abcdc1d45431839ebf5c83 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 21 Oct 2020 17:19:30 -0300 Subject: [PATCH] Make MANAGE_DOCUMENTS permission optional for those who can not or do not want to use it Fixes #58 --- README.md | 2 +- .../seedvault/ui/storage/StorageActivity.kt | 68 ++++++++++- .../ui/storage/StorageRootFetcher.kt | 111 ++--------------- .../ui/storage/StorageRootResolver.kt | 113 ++++++++++++++++++ .../ui/storage/StorageRootsFragment.kt | 3 + whitelist_com.stevesoltys.seedvault.xml | 3 +- 6 files changed, 190 insertions(+), 110 deletions(-) create mode 100644 app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt diff --git a/README.md b/README.md index 4dd2ad78..02d5a7d9 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,11 @@ It uses the same internal APIs as `adb backup` which is deprecated and thus need ## Permissions * `android.permission.BACKUP` to back up application data. * `android.permission.ACCESS_NETWORK_STATE` to check if there is internet access when network storage is used. -* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots. * `android.permission.MANAGE_USB` to access the serial number of USB mass storage devices. * `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings and enable call log backup. * `android.permission.QUERY_ALL_PACKAGES` to get information about all installed apps for backup. * `android.permission.INSTALL_PACKAGES` to re-install apps when restoring from backup. +* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots (optional) for better UX. ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/stevesoltys/seedvault. diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt index 10350ea2..873bd7d2 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageActivity.kt @@ -1,7 +1,16 @@ package com.stevesoltys.seedvault.ui.storage +import android.Manifest.permission.MANAGE_DOCUMENTS +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION +import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION +import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.net.Uri import android.os.Bundle import android.util.Log +import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree import androidx.annotation.CallSuper import androidx.appcompat.app.AlertDialog import com.stevesoltys.seedvault.R @@ -17,6 +26,24 @@ class StorageActivity : BackupActivity() { private lateinit var viewModel: StorageViewModel + /** + * The official way to get a SAF [Uri] which we only use if we don't have the + * [MANAGE_DOCUMENTS] permission (via [canUseStorageRootsFragment]). + */ + private val openDocumentTree = registerForActivityResult(OpenPersistableDocumentTree()) { uri -> + if (uri != null) { + Log.e(TAG, "OpenDocumentTree: $uri") + val authority = uri.authority ?: throw AssertionError("No authority in $uri") + val storageRoot = StorageRootResolver.getStorageRoots(this, authority).getOrNull(0) + if (storageRoot == null) { + viewModel.onUriPermissionResultReceived(null) + } else { + viewModel.onStorageRootChosen(storageRoot) + viewModel.onUriPermissionResultReceived(uri) + } + } + } + @CallSuper override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -47,7 +74,11 @@ class StorageActivity : BackupActivity() { }) if (savedInstanceState == null) { - showFragment(StorageRootsFragment.newInstance(isRestore())) + if (canUseStorageRootsFragment()) { + showFragment(StorageRootsFragment.newInstance(isRestore())) + } else { + openDocumentTree.launch(null) + } } } @@ -61,13 +92,28 @@ class StorageActivity : BackupActivity() { private fun onInvalidLocation(errorMsg: String) { if (viewModel.isRestoreOperation) { - supportFragmentManager.popBackStack() - AlertDialog.Builder(this) + val dialog = AlertDialog.Builder(this) .setTitle(getString(R.string.restore_invalid_location_title)) .setMessage(errorMsg) .setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() } - .show() + if (canUseStorageRootsFragment()) { + // We have permission to use StorageRootsFragment, + // so pop the back stack to show it again + supportFragmentManager.popBackStack() + dialog.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() } + } else { + // We don't have permission to use StorageRootsFragment, + // so give option to choose again or cancel. + dialog.setPositiveButton(android.R.string.ok) { _, _ -> + openDocumentTree.launch(null) + } + dialog.setNegativeButton(android.R.string.cancel) { _, _ -> + finishAfterTransition() + } + } + dialog.show() } else { + // just show error message, if this isn't restore showFragment(StorageCheckFragment.newInstance(getCheckFragmentTitle(), errorMsg)) } } @@ -80,6 +126,10 @@ class StorageActivity : BackupActivity() { return intent?.getBooleanExtra(INTENT_EXTRA_IS_SETUP_WIZARD, false) ?: false } + private fun canUseStorageRootsFragment(): Boolean { + return checkSelfPermission(MANAGE_DOCUMENTS) == PERMISSION_GRANTED + } + private fun getCheckFragmentTitle() = if (viewModel.isRestoreOperation) { getString(R.string.storage_check_fragment_restore_title) } else { @@ -87,3 +137,13 @@ class StorageActivity : BackupActivity() { } } + +private class OpenPersistableDocumentTree : OpenDocumentTree() { + override fun createIntent(context: Context, input: Uri?): Intent { + return super.createIntent(context, input).apply { + val flags = FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION + addFlags(flags) + } + } +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootFetcher.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootFetcher.kt index 8e41cc88..643435e8 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootFetcher.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootFetcher.kt @@ -8,27 +8,15 @@ import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.pm.PackageManager.GET_META_DATA import android.content.pm.ProviderInfo import android.database.ContentObserver -import android.database.Cursor import android.graphics.drawable.Drawable import android.net.Uri import android.os.Handler import android.os.Looper import android.provider.DocumentsContract import android.provider.DocumentsContract.PROVIDER_INTERFACE -import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES -import android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID -import android.provider.DocumentsContract.Root.COLUMN_FLAGS -import android.provider.DocumentsContract.Root.COLUMN_ICON -import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID -import android.provider.DocumentsContract.Root.COLUMN_SUMMARY -import android.provider.DocumentsContract.Root.COLUMN_TITLE -import android.provider.DocumentsContract.Root.FLAG_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 -import java.lang.Long.parseLong +import com.stevesoltys.seedvault.ui.storage.StorageRootResolver.getIcon private val TAG = StorageRootFetcher::class.java.simpleName @@ -116,51 +104,12 @@ internal class StorageRootFetcher(private val context: Context, private val isRe private fun getRoots(providerInfo: ProviderInfo): List { val authority = providerInfo.authority val provider = packageManager.resolveContentProvider(authority, GET_META_DATA) - if (provider == null || !provider.isSupported()) { + return if (provider == null || !provider.isSupported()) { Log.w(TAG, "Failed to get provider info for $authority") - return emptyList() + emptyList() + } else { + StorageRootResolver.getStorageRoots(context, authority) } - - val roots = ArrayList() - val rootsUri = DocumentsContract.buildRootsUri(authority) - - var cursor: Cursor? = null - try { - cursor = contentResolver.query(rootsUri, null, null, null, null) - while (cursor!!.moveToNext()) { - val root = getStorageRoot(authority, cursor) - if (root != null) roots.add(root) - } - } catch (e: Exception) { - Log.w(TAG, "Failed to load some roots from $authority", e) - } finally { - cursor?.close() - } - return roots - } - - private fun getStorageRoot(authority: String, cursor: Cursor): StorageRoot? { - val flags = cursor.getInt(COLUMN_FLAGS) - val supportsCreate = flags and FLAG_SUPPORTS_CREATE != 0 - val supportsIsChild = flags and FLAG_SUPPORTS_IS_CHILD != 0 - if (!supportsCreate || !supportsIsChild) return null - val rootId = cursor.getString(COLUMN_ROOT_ID)!! - if (authority == AUTHORITY_STORAGE && rootId == ROOT_ID_HOME) return null - 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 checkOrAddUsbRoot(roots: ArrayList) { @@ -256,55 +205,9 @@ internal class StorageRootFetcher(private val context: Context, private val isRe } private fun isAuthoritySupported(authority: String): Boolean { - // just restrict where to store backups, restoring can be more free for forward compatibility + // just restrict where to store backups, + // restoring can be more free for forward compatibility return isRestore || whitelistedAuthorities.contains(authority) } - private fun Cursor.getString(columnName: String): String? { - val index = getColumnIndex(columnName) - return if (index != -1) getString(index) else null - } - - private fun Cursor.getInt(columnName: String): Int { - val index = getColumnIndex(columnName) - return if (index != -1) getInt(index) else 0 - } - - private fun Cursor.getLong(columnName: String): Long? { - val index = getColumnIndex(columnName) - if (index == -1) return null - val value = getString(index) ?: return null - return try { - parseLong(value) - } catch (e: NumberFormatException) { - null - } - } - - private fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? { - return getPackageIcon(context, authority, icon) ?: when { - authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> { - context.getDrawable(R.drawable.ic_phone_android) - } - authority == AUTHORITY_STORAGE && rootId != ROOT_ID_HOME -> { - context.getDrawable(R.drawable.ic_usb) - } - authority == AUTHORITY_NEXTCLOUD -> { - context.getDrawable(R.drawable.nextcloud) - } - else -> null - } - } - - private fun getPackageIcon(context: Context, authority: String, icon: Int): Drawable? { - if (icon != 0) { - val pm = context.packageManager - val info = pm.resolveContentProvider(authority, 0) - if (info != null) { - return pm.getDrawable(info.packageName, icon, info.applicationInfo) - } - } - return null - } - } diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt new file mode 100644 index 00000000..07318440 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt @@ -0,0 +1,113 @@ +package com.stevesoltys.seedvault.ui.storage + +import android.content.Context +import android.database.Cursor +import android.graphics.drawable.Drawable +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES +import android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID +import android.provider.DocumentsContract.Root.COLUMN_FLAGS +import android.provider.DocumentsContract.Root.COLUMN_ICON +import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID +import android.provider.DocumentsContract.Root.COLUMN_SUMMARY +import android.provider.DocumentsContract.Root.COLUMN_TITLE +import android.provider.DocumentsContract.Root.FLAG_LOCAL_ONLY +import android.provider.DocumentsContract.Root.FLAG_REMOVABLE_USB +import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_CREATE +import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD +import android.util.Log +import com.stevesoltys.seedvault.R + +object StorageRootResolver { + + private val TAG = StorageRootResolver::class.java.simpleName + + fun getStorageRoots(context: Context, authority: String): List { + val roots = ArrayList() + val rootsUri = DocumentsContract.buildRootsUri(authority) + + try { + context.contentResolver.query(rootsUri, null, null, null, null)?.use { cursor -> + while (cursor.moveToNext()) { + val root = getStorageRoot(context, authority, cursor) + if (root != null) roots.add(root) + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to load some roots from $authority", e) + } + return roots + } + + private fun getStorageRoot(context: Context, authority: String, cursor: Cursor): StorageRoot? { + val flags = cursor.getInt(COLUMN_FLAGS) + val supportsCreate = flags and FLAG_SUPPORTS_CREATE != 0 + val supportsIsChild = flags and FLAG_SUPPORTS_IS_CHILD != 0 + if (!supportsCreate || !supportsIsChild) return null + val rootId = cursor.getString(COLUMN_ROOT_ID)!! + if (authority == AUTHORITY_STORAGE && rootId == ROOT_ID_HOME) return null + val documentId = cursor.getString(COLUMN_DOCUMENT_ID) ?: return null + return StorageRoot( + authority = authority, + rootId = rootId, + documentId = documentId, + icon = getIcon(context, authority, rootId, cursor.getInt(COLUMN_ICON)), + title = cursor.getString(COLUMN_TITLE)!!, + summary = cursor.getString(COLUMN_SUMMARY), + availableBytes = cursor.getLong(COLUMN_AVAILABLE_BYTES).let { bytes -> + // AOSP 11 reports -1 instead of null + if (bytes == -1L) null else bytes + }, + isUsb = flags and FLAG_REMOVABLE_USB != 0, + requiresNetwork = flags and FLAG_LOCAL_ONLY == 0 // not local only == requires network + ) + } + + private fun Cursor.getString(columnName: String): String? { + val index = getColumnIndex(columnName) + return if (index != -1) getString(index) else null + } + + private fun Cursor.getInt(columnName: String): Int { + val index = getColumnIndex(columnName) + return if (index != -1) getInt(index) else 0 + } + + private fun Cursor.getLong(columnName: String): Long? { + val index = getColumnIndex(columnName) + if (index == -1) return null + val value = getString(index) ?: return null + return try { + java.lang.Long.parseLong(value) + } catch (e: NumberFormatException) { + null + } + } + + fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? { + return getPackageIcon(context, authority, icon) ?: when { + authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> { + context.getDrawable(R.drawable.ic_phone_android) + } + authority == AUTHORITY_STORAGE && rootId != ROOT_ID_HOME -> { + context.getDrawable(R.drawable.ic_usb) + } + authority == AUTHORITY_NEXTCLOUD -> { + context.getDrawable(R.drawable.nextcloud) + } + else -> null + } + } + + private fun getPackageIcon(context: Context, authority: String, icon: Int): Drawable? { + if (icon != 0) { + val pm = context.packageManager + val info = pm.resolveContentProvider(authority, 0) + if (info != null) { + return pm.getDrawable(info.packageName, icon, info.applicationInfo) + } + } + return null + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootsFragment.kt index 70c79825..ae872ad1 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootsFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootsFragment.kt @@ -1,5 +1,6 @@ package com.stevesoltys.seedvault.ui.storage +import android.Manifest.permission.MANAGE_DOCUMENTS import android.annotation.SuppressLint import android.content.Context import android.content.Intent @@ -17,6 +18,7 @@ import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree +import androidx.annotation.RequiresPermission import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import androidx.recyclerview.widget.RecyclerView @@ -27,6 +29,7 @@ import org.koin.androidx.viewmodel.ext.android.getSharedViewModel internal class StorageRootsFragment : Fragment(), StorageRootClickedListener { companion object { + @RequiresPermission(MANAGE_DOCUMENTS) fun newInstance(isRestore: Boolean): StorageRootsFragment { val f = StorageRootsFragment() f.arguments = Bundle().apply { diff --git a/whitelist_com.stevesoltys.seedvault.xml b/whitelist_com.stevesoltys.seedvault.xml index 64321699..c3c2877b 100644 --- a/whitelist_com.stevesoltys.seedvault.xml +++ b/whitelist_com.stevesoltys.seedvault.xml @@ -1,5 +1,6 @@ + service="com.stevesoltys.seedvault/.transport.ConfigurableBackupTransportService" /> +