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 ## Permissions
* `android.permission.BACKUP` to back up application data. * `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.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.MANAGE_USB` to access the serial number of USB mass storage devices.
* `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings and enable call log backup. * `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings and enable call log backup.
* `android.permission.QUERY_ALL_PACKAGES` to get information about all installed apps for backup. * `android.permission.QUERY_ALL_PACKAGES` to get information about all installed apps for backup.
* `android.permission.INSTALL_PACKAGES` to re-install apps when restoring from backup. * `android.permission.INSTALL_PACKAGES` to re-install apps when restoring from backup.
* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots (optional) for better UX.
## Contributing ## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/stevesoltys/seedvault. Bug reports and pull requests are welcome on GitHub at https://github.com/stevesoltys/seedvault.

View file

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

View file

@ -8,27 +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_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 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
@ -116,51 +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
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>) { 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 { private fun isAuthoritySupported(authority: String): Boolean {
// just restrict where to store backups, restoring can be more free for forward compatibility // just restrict where to store backups,
// restoring can be more free for forward compatibility
return isRestore || whitelistedAuthorities.contains(authority) return isRestore || whitelistedAuthorities.contains(authority)
} }
private fun Cursor.getString(columnName: String): String? {
val index = getColumnIndex(columnName)
return if (index != -1) getString(index) else null
}
private fun Cursor.getInt(columnName: String): Int {
val index = getColumnIndex(columnName)
return if (index != -1) getInt(index) else 0
}
private fun Cursor.getLong(columnName: String): Long? {
val index = getColumnIndex(columnName)
if (index == -1) return null
val value = getString(index) ?: return null
return try {
parseLong(value)
} catch (e: NumberFormatException) {
null
}
}
private fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? {
return getPackageIcon(context, authority, icon) ?: when {
authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> {
context.getDrawable(R.drawable.ic_phone_android)
}
authority == AUTHORITY_STORAGE && rootId != ROOT_ID_HOME -> {
context.getDrawable(R.drawable.ic_usb)
}
authority == AUTHORITY_NEXTCLOUD -> {
context.getDrawable(R.drawable.nextcloud)
}
else -> null
}
}
private fun getPackageIcon(context: Context, authority: String, icon: Int): Drawable? {
if (icon != 0) {
val pm = context.packageManager
val info = pm.resolveContentProvider(authority, 0)
if (info != null) {
return pm.getDrawable(info.packageName, icon, info.applicationInfo)
}
}
return null
}
} }

View file

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

View file

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

View file

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