Make MANAGE_DOCUMENTS permission optional

for those who can not or do not want to use it

Fixes #58
This commit is contained in:
Torsten Grote 2020-10-21 17:19:30 -03:00
parent a69794a6d0
commit e9f3c08220
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
6 changed files with 190 additions and 110 deletions

View file

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

View file

@ -1,7 +1,16 @@
package com.stevesoltys.seedvault.ui.storage
import android.Manifest.permission.MANAGE_DOCUMENTS
import android.content.Context
import android.content.Intent
import android.content.Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
import androidx.annotation.CallSuper
import androidx.appcompat.app.AlertDialog
import com.stevesoltys.seedvault.R
@ -17,6 +26,24 @@ class StorageActivity : BackupActivity() {
private lateinit var viewModel: StorageViewModel
/**
* The official way to get a SAF [Uri] which we only use if we don't have the
* [MANAGE_DOCUMENTS] permission (via [canUseStorageRootsFragment]).
*/
private val openDocumentTree = registerForActivityResult(OpenPersistableDocumentTree()) { uri ->
if (uri != null) {
Log.e(TAG, "OpenDocumentTree: $uri")
val authority = uri.authority ?: throw AssertionError("No authority in $uri")
val storageRoot = StorageRootResolver.getStorageRoots(this, authority).getOrNull(0)
if (storageRoot == null) {
viewModel.onUriPermissionResultReceived(null)
} else {
viewModel.onStorageRootChosen(storageRoot)
viewModel.onUriPermissionResultReceived(uri)
}
}
}
@CallSuper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -47,7 +74,11 @@ class StorageActivity : BackupActivity() {
})
if (savedInstanceState == null) {
showFragment(StorageRootsFragment.newInstance(isRestore()))
if (canUseStorageRootsFragment()) {
showFragment(StorageRootsFragment.newInstance(isRestore()))
} else {
openDocumentTree.launch(null)
}
}
}
@ -61,13 +92,28 @@ class StorageActivity : BackupActivity() {
private fun onInvalidLocation(errorMsg: String) {
if (viewModel.isRestoreOperation) {
supportFragmentManager.popBackStack()
AlertDialog.Builder(this)
val dialog = AlertDialog.Builder(this)
.setTitle(getString(R.string.restore_invalid_location_title))
.setMessage(errorMsg)
.setPositiveButton(android.R.string.ok) { dialog, _ -> dialog.dismiss() }
.show()
if (canUseStorageRootsFragment()) {
// We have permission to use StorageRootsFragment,
// so pop the back stack to show it again
supportFragmentManager.popBackStack()
dialog.setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() }
} else {
// We don't have permission to use StorageRootsFragment,
// so give option to choose again or cancel.
dialog.setPositiveButton(android.R.string.ok) { _, _ ->
openDocumentTree.launch(null)
}
dialog.setNegativeButton(android.R.string.cancel) { _, _ ->
finishAfterTransition()
}
}
dialog.show()
} else {
// just show error message, if this isn't restore
showFragment(StorageCheckFragment.newInstance(getCheckFragmentTitle(), errorMsg))
}
}
@ -80,6 +126,10 @@ class StorageActivity : BackupActivity() {
return intent?.getBooleanExtra(INTENT_EXTRA_IS_SETUP_WIZARD, false) ?: false
}
private fun canUseStorageRootsFragment(): Boolean {
return checkSelfPermission(MANAGE_DOCUMENTS) == PERMISSION_GRANTED
}
private fun getCheckFragmentTitle() = if (viewModel.isRestoreOperation) {
getString(R.string.storage_check_fragment_restore_title)
} else {
@ -87,3 +137,13 @@ class StorageActivity : BackupActivity() {
}
}
private class OpenPersistableDocumentTree : OpenDocumentTree() {
override fun createIntent(context: Context, input: Uri?): Intent {
return super.createIntent(context, input).apply {
val flags = FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION
addFlags(flags)
}
}
}

View file

@ -8,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<StorageRoot> {
val authority = providerInfo.authority
val provider = packageManager.resolveContentProvider(authority, GET_META_DATA)
if (provider == null || !provider.isSupported()) {
return if (provider == null || !provider.isSupported()) {
Log.w(TAG, "Failed to get provider info for $authority")
return emptyList()
emptyList()
} else {
StorageRootResolver.getStorageRoots(context, authority)
}
val roots = ArrayList<StorageRoot>()
val rootsUri = DocumentsContract.buildRootsUri(authority)
var cursor: Cursor? = null
try {
cursor = contentResolver.query(rootsUri, null, null, null, null)
while (cursor!!.moveToNext()) {
val root = getStorageRoot(authority, cursor)
if (root != null) roots.add(root)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to load some roots from $authority", e)
} finally {
cursor?.close()
}
return roots
}
private fun getStorageRoot(authority: String, cursor: Cursor): StorageRoot? {
val flags = cursor.getInt(COLUMN_FLAGS)
val supportsCreate = flags and FLAG_SUPPORTS_CREATE != 0
val supportsIsChild = flags and FLAG_SUPPORTS_IS_CHILD != 0
if (!supportsCreate || !supportsIsChild) return null
val rootId = cursor.getString(COLUMN_ROOT_ID)!!
if (authority == AUTHORITY_STORAGE && rootId == ROOT_ID_HOME) return null
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<StorageRoot>) {
@ -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
}
}

View file

@ -0,0 +1,113 @@
package com.stevesoltys.seedvault.ui.storage
import android.content.Context
import android.database.Cursor
import android.graphics.drawable.Drawable
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
import android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID
import android.provider.DocumentsContract.Root.COLUMN_FLAGS
import android.provider.DocumentsContract.Root.COLUMN_ICON
import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID
import android.provider.DocumentsContract.Root.COLUMN_SUMMARY
import android.provider.DocumentsContract.Root.COLUMN_TITLE
import android.provider.DocumentsContract.Root.FLAG_LOCAL_ONLY
import android.provider.DocumentsContract.Root.FLAG_REMOVABLE_USB
import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_CREATE
import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
import android.util.Log
import com.stevesoltys.seedvault.R
object StorageRootResolver {
private val TAG = StorageRootResolver::class.java.simpleName
fun getStorageRoots(context: Context, authority: String): List<StorageRoot> {
val roots = ArrayList<StorageRoot>()
val rootsUri = DocumentsContract.buildRootsUri(authority)
try {
context.contentResolver.query(rootsUri, null, null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val root = getStorageRoot(context, authority, cursor)
if (root != null) roots.add(root)
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to load some roots from $authority", e)
}
return roots
}
private fun getStorageRoot(context: Context, authority: String, cursor: Cursor): StorageRoot? {
val flags = cursor.getInt(COLUMN_FLAGS)
val supportsCreate = flags and FLAG_SUPPORTS_CREATE != 0
val supportsIsChild = flags and FLAG_SUPPORTS_IS_CHILD != 0
if (!supportsCreate || !supportsIsChild) return null
val rootId = cursor.getString(COLUMN_ROOT_ID)!!
if (authority == AUTHORITY_STORAGE && rootId == ROOT_ID_HOME) return null
val documentId = cursor.getString(COLUMN_DOCUMENT_ID) ?: return null
return StorageRoot(
authority = authority,
rootId = rootId,
documentId = documentId,
icon = getIcon(context, authority, rootId, cursor.getInt(COLUMN_ICON)),
title = cursor.getString(COLUMN_TITLE)!!,
summary = cursor.getString(COLUMN_SUMMARY),
availableBytes = cursor.getLong(COLUMN_AVAILABLE_BYTES).let { bytes ->
// AOSP 11 reports -1 instead of null
if (bytes == -1L) null else bytes
},
isUsb = flags and FLAG_REMOVABLE_USB != 0,
requiresNetwork = flags and FLAG_LOCAL_ONLY == 0 // not local only == requires network
)
}
private fun Cursor.getString(columnName: String): String? {
val index = getColumnIndex(columnName)
return if (index != -1) getString(index) else null
}
private fun Cursor.getInt(columnName: String): Int {
val index = getColumnIndex(columnName)
return if (index != -1) getInt(index) else 0
}
private fun Cursor.getLong(columnName: String): Long? {
val index = getColumnIndex(columnName)
if (index == -1) return null
val value = getString(index) ?: return null
return try {
java.lang.Long.parseLong(value)
} catch (e: NumberFormatException) {
null
}
}
fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? {
return getPackageIcon(context, authority, icon) ?: when {
authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> {
context.getDrawable(R.drawable.ic_phone_android)
}
authority == AUTHORITY_STORAGE && rootId != ROOT_ID_HOME -> {
context.getDrawable(R.drawable.ic_usb)
}
authority == AUTHORITY_NEXTCLOUD -> {
context.getDrawable(R.drawable.nextcloud)
}
else -> null
}
}
private fun getPackageIcon(context: Context, authority: String, icon: Int): Drawable? {
if (icon != 0) {
val pm = context.packageManager
val info = pm.resolveContentProvider(authority, 0)
if (info != null) {
return pm.getDrawable(info.packageName, icon, info.applicationInfo)
}
}
return null
}
}

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.ui.storage
import android.Manifest.permission.MANAGE_DOCUMENTS
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
@ -17,6 +18,7 @@ import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
import androidx.annotation.RequiresPermission
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.RecyclerView
@ -27,6 +29,7 @@ import org.koin.androidx.viewmodel.ext.android.getSharedViewModel
internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
companion object {
@RequiresPermission(MANAGE_DOCUMENTS)
fun newInstance(isRestore: Boolean): StorageRootsFragment {
val f = StorageRootsFragment()
f.arguments = Bundle().apply {

View file

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