Make MANAGE_DOCUMENTS permission optional
for those who can not or do not want to use it Fixes #58
This commit is contained in:
parent
a69794a6d0
commit
e9f3c08220
6 changed files with 190 additions and 110 deletions
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue