android 11: improved handling and feedback for restricted directories

This commit is contained in:
Thibault Deckers 2021-02-16 12:18:59 +09:00
parent 7252346444
commit 9173ee9121
12 changed files with 181 additions and 54 deletions

View file

@ -14,10 +14,6 @@
https://developer.android.com/preview/privacy/storage#media-file-access https://developer.android.com/preview/privacy/storage#media-file-access
- raw path access: - raw path access:
https://developer.android.com/preview/privacy/storage#media-files-raw-paths https://developer.android.com/preview/privacy/storage#media-files-raw-paths
Android R issues:
- users cannot grant directory access to the root Downloads directory,
- users cannot grant directory access to the root directory of each reliable SD card volume
--> -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

View file

@ -325,7 +325,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) { if (xmpMeta.doesPropertyExist(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)) {
val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME) val count = xmpMeta.countArrayItems(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME)
val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME, it).value } val values = (1 until count + 1).map { xmpMeta.getArrayItem(XMP.DC_SCHEMA_NS, XMP.SUBJECT_PROP_NAME, it).value }
metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(separator = XMP_SUBJECTS_SEPARATOR) metadataMap[KEY_XMP_SUBJECTS] = values.joinToString(XMP_SUBJECTS_SEPARATOR)
} }
xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it } xmpMeta.getSafeLocalizedText(XMP.DC_SCHEMA_NS, XMP.TITLE_PROP_NAME, acceptBlank = false) { metadataMap[KEY_XMP_TITLE_DESCRIPTION] = it }
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) { if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
@ -350,7 +350,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
// XMP fallback to IPTC // XMP fallback to IPTC
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) { if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(IptcDirectory::class.java)) {
dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(separator = XMP_SUBJECTS_SEPARATOR) } dir.keywords?.let { metadataMap[KEY_XMP_SUBJECTS] = it.joinToString(XMP_SUBJECTS_SEPARATOR) }
} }
} }

View file

@ -26,6 +26,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
"getFreeSpace" -> safe(call, result, ::getFreeSpace) "getFreeSpace" -> safe(call, result, ::getFreeSpace)
"getGrantedDirectories" -> safe(call, result, ::getGrantedDirectories) "getGrantedDirectories" -> safe(call, result, ::getGrantedDirectories)
"getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories) "getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories)
"getRestrictedDirectories" -> safe(call, result, ::getRestrictedDirectories)
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess) "revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) } "scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
else -> result.notImplemented() else -> result.notImplemented()
@ -104,8 +105,11 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
return return
} }
val dirs = PermissionManager.getInaccessibleDirectories(context, dirPaths) result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths))
result.success(dirs) }
private fun getRestrictedDirectories(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
result.success(PermissionManager.getRestrictedDirectories(context))
} }
private fun revokeDirectoryAccess(call: MethodCall, result: MethodChannel.Result) { private fun revokeDirectoryAccess(call: MethodCall, result: MethodChannel.Result) {

View file

@ -5,12 +5,14 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.util.Log import android.util.Log
import deckers.thibault.aves.utils.StorageUtils.PathSegments import deckers.thibault.aves.utils.StorageUtils.PathSegments
import java.io.File import java.io.File
import java.util.* import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.collections.ArrayList
object PermissionManager { object PermissionManager {
private val LOG_TAG = LogUtils.createTag(PermissionManager::class.java) private val LOG_TAG = LogUtils.createTag(PermissionManager::class.java)
@ -66,9 +68,20 @@ object PermissionManager {
val dirSet = dirsPerVolume[volumePath] ?: HashSet() val dirSet = dirsPerVolume[volumePath] ?: HashSet()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// request primary directory on volume from Android R // request primary directory on volume from Android R
segments.relativeDir?.apply { val relativeDir = segments.relativeDir
val primaryDir = split(File.separator).firstOrNull { it.isNotEmpty() } if (relativeDir != null) {
primaryDir?.let { dirSet.add(it) } val dirSegments = relativeDir.split(File.separator).takeWhile { it.isNotEmpty() }
val primaryDir = dirSegments.firstOrNull()
if (primaryDir == Environment.DIRECTORY_DOWNLOADS && dirSegments.size > 1) {
// request secondary directory (if any) for restricted primary directory
dirSet.add(dirSegments.take(2).joinToString(File.separator))
} else {
primaryDir?.let { dirSet.add(it) }
}
} else {
// the requested path is the volume root itself
// which cannot be granted, due to Android R restrictions
dirSet.add("")
} }
} else { } else {
// request volume root until Android Q // request volume root until Android Q
@ -92,6 +105,30 @@ object PermissionManager {
} }
} }
fun getRestrictedDirectories(context: Context): List<Map<String, String>> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// cf https://developer.android.com/about/versions/11/privacy/storage#directory-access
val volumePaths = StorageUtils.getVolumePaths(context)
ArrayList<Map<String, String>>().apply {
addAll(volumePaths.map {
hashMapOf(
"volumePath" to it,
"relativeDir" to "",
)
})
addAll(volumePaths.map {
hashMapOf(
"volumePath" to it,
"relativeDir" to Environment.DIRECTORY_DOWNLOADS,
)
})
}
} else {
// TODO TLAD add KitKat restriction (no SD card root access) if min version goes to API 19-20
ArrayList()
}
}
fun revokeDirectoryAccess(context: Context, path: String): Boolean { fun revokeDirectoryAccess(context: Context, path: String): Boolean {
return StorageUtils.convertDirPathToTreeUri(context, path)?.let { return StorageUtils.convertDirPathToTreeUri(context, path)?.let {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION

View file

@ -23,34 +23,36 @@ mixin AlbumMixin on SourceBase {
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent()); void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
String getUniqueAlbumName(String album) { String getUniqueAlbumName(String dirPath) {
final otherAlbums = _directories.where((item) => item != album); String unique(String dirPath, [bool Function(String) test]) {
final parts = album.split(separator); final otherAlbums = _directories.where(test ?? (_) => true).where((item) => item != dirPath);
var partCount = 0; final parts = dirPath.split(separator);
String testName; var partCount = 0;
do { String testName;
testName = separator + parts.skip(parts.length - ++partCount).join(separator); do {
} while (otherAlbums.any((item) => item.endsWith(testName))); testName = separator + parts.skip(parts.length - ++partCount).join(separator);
final uniqueName = parts.skip(parts.length - partCount).join(separator); } while (otherAlbums.any((item) => item.endsWith(testName)));
final uniqueName = parts.skip(parts.length - partCount).join(separator);
final volume = androidFileUtils.getStorageVolume(album);
if (volume == null) {
return uniqueName; return uniqueName;
} }
final volumeRootLength = volume.path.length; final dir = VolumeRelativeDirectory.fromPath(dirPath);
if (album.length < volumeRootLength) { if (dir == null) return dirPath;
// `album` is at the root, without trailing '/'
return uniqueName;
}
final albumRelativePath = album.substring(volumeRootLength); final uniqueNameInDevice = unique(dirPath);
if (uniqueName.length < albumRelativePath.length) { final relativeDir = dir.relativeDir;
return uniqueName; if (relativeDir.isEmpty) return uniqueNameInDevice;
} else if (volume.isPrimary) {
return albumRelativePath; if (uniqueNameInDevice.length < relativeDir.length) {
return uniqueNameInDevice;
} else { } else {
return '$albumRelativePath (${volume.description})'; final uniqueNameInVolume = unique(dirPath, (item) => item.startsWith(dir.volumePath));
final volume = androidFileUtils.getStorageVolume(dirPath);
if (volume.isPrimary) {
return uniqueNameInVolume;
} else {
return '$uniqueNameInVolume (${volume.description})';
}
} }
} }

View file

@ -52,20 +52,28 @@ class AndroidFileService {
return; return;
} }
// returns a list of directories, static Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
// each directory is a map with "volumePath", "relativeDir"
static Future<List<Map>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
try { try {
final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{ final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{
'dirPaths': dirPaths.toList(), 'dirPaths': dirPaths.toList(),
}); });
return (result as List).cast<Map>(); return (result as List).cast<Map>().map((map) => VolumeRelativeDirectory.fromMap(map)).toSet();
} on PlatformException catch (e) { } on PlatformException catch (e) {
debugPrint('getInaccessibleDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); debugPrint('getInaccessibleDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
} }
return null; return null;
} }
static Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories() async {
try {
final result = await platform.invokeMethod('getRestrictedDirectories');
return (result as List).cast<Map>().map((map) => VolumeRelativeDirectory.fromMap(map)).toSet();
} on PlatformException catch (e) {
debugPrint('getRestrictedDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
}
return null;
}
// returns whether user granted access to volume root at `volumePath` // returns whether user granted access to volume root at `volumePath`
static Future<bool> requestVolumeAccess(String volumePath) async { static Future<bool> requestVolumeAccess(String volumePath) async {
try { try {

View file

@ -2,6 +2,7 @@ import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/android_file_service.dart'; import 'package:aves/services/android_file_service.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
@ -112,6 +113,7 @@ class Package {
String toString() => '$runtimeType#${shortHash(this)}{packageName=$packageName, categoryLauncher=$categoryLauncher, isSystem=$isSystem, currentLabel=$currentLabel, englishLabel=$englishLabel, ownedDirs=$ownedDirs}'; String toString() => '$runtimeType#${shortHash(this)}{packageName=$packageName, categoryLauncher=$categoryLauncher, isSystem=$isSystem, currentLabel=$currentLabel, englishLabel=$englishLabel, ownedDirs=$ownedDirs}';
} }
@immutable
class StorageVolume { class StorageVolume {
final String description, path, state; final String description, path, state;
final bool isPrimary, isRemovable; final bool isPrimary, isRemovable;
@ -135,3 +137,49 @@ class StorageVolume {
); );
} }
} }
@immutable
class VolumeRelativeDirectory {
final String volumePath, relativeDir;
const VolumeRelativeDirectory({
this.volumePath,
this.relativeDir,
});
factory VolumeRelativeDirectory.fromMap(Map map) {
return VolumeRelativeDirectory(
volumePath: map['volumePath'],
relativeDir: map['relativeDir'] ?? '',
);
}
// prefer static method over a null returning factory constructor
static VolumeRelativeDirectory fromPath(String dirPath) {
final volume = androidFileUtils.getStorageVolume(dirPath);
if (volume == null) return null;
final root = volume.path;
final rootLength = root.length;
return VolumeRelativeDirectory(
volumePath: root,
relativeDir: dirPath.length < rootLength ? '' : dirPath.substring(rootLength),
);
}
String get directoryDescription => relativeDir.isEmpty ? 'root' : '$relativeDir';
String get volumeDescription {
final volume = androidFileUtils.storageVolumes.firstWhere((volume) => volume.path == volumePath, orElse: () => null);
return volume?.description ?? volumePath;
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is VolumeRelativeDirectory && other.volumePath == volumePath && other.relativeDir == relativeDir;
}
@override
int get hashCode => hashValues(volumePath, relativeDir);
}

View file

@ -7,8 +7,10 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/android_file_service.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart';
@ -63,6 +65,20 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
} }
Future<void> _moveSelection(BuildContext context, {@required MoveType moveType}) async { Future<void> _moveSelection(BuildContext context, {@required MoveType moveType}) async {
final selectionDirs = selection.where((e) => e.path != null).map((e) => e.directory).toSet();
if (moveType == MoveType.move) {
// check whether moving is possible given OS restrictions,
// before asking to pick a destination album
final restrictedDirs = await AndroidFileService.getRestrictedDirectories();
for (final selectionDir in selectionDirs) {
final dir = VolumeRelativeDirectory.fromPath(selectionDir);
if (restrictedDirs.contains(dir)) {
await showRestrictedDirectoryDialog(context, dir);
return;
}
}
}
final destinationAlbum = await Navigator.push( final destinationAlbum = await Navigator.push(
context, context,
MaterialPageRoute<String>( MaterialPageRoute<String>(
@ -73,7 +89,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
if (destinationAlbum == null || destinationAlbum.isEmpty) return; if (destinationAlbum == null || destinationAlbum.isEmpty) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
if (!await checkStoragePermission(context, selection)) return; if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs)) return;
if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, moveType)) return; if (!await checkFreeSpaceForMove(context, selection, destinationAlbum, moveType)) return;

View file

@ -87,10 +87,7 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
Future<void> onComplete() => _animationController.reverse().then((_) => widget.onDone(processed)); Future<void> onComplete() => _animationController.reverse().then((_) => widget.onDone(processed));
opStream.listen( opStream.listen(
processed.add, processed.add,
onError: (error) { onError: (error) => debugPrint('_showOpReport error=$error'),
debugPrint('_showOpReport error=$error');
onComplete();
},
onDone: onComplete, onDone: onComplete,
); );
} }

View file

@ -10,26 +10,26 @@ mixin PermissionAwareMixin {
} }
Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async { Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async {
final restrictedDirs = await AndroidFileService.getRestrictedDirectories();
while (true) { while (true) {
final dirs = await AndroidFileService.getInaccessibleDirectories(albumPaths); final dirs = await AndroidFileService.getInaccessibleDirectories(albumPaths);
if (dirs == null) return false; if (dirs == null) return false;
if (dirs.isEmpty) return true; if (dirs.isEmpty) return true;
final restrictedInaccessibleDir = dirs.firstWhere(restrictedDirs.contains, orElse: () => null);
if (restrictedInaccessibleDir != null) {
await showRestrictedDirectoryDialog(context, restrictedInaccessibleDir);
return false;
}
final dir = dirs.first; final dir = dirs.first;
final volumePath = dir['volumePath'] as String;
final relativeDir = dir['relativeDir'] as String;
final volume = androidFileUtils.storageVolumes.firstWhere((volume) => volume.path == volumePath, orElse: () => null);
final volumeDescription = volume?.description ?? volumePath;
final dirDisplayName = relativeDir.isEmpty ? 'root' : '$relativeDir';
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
return AvesDialog( return AvesDialog(
context: context, context: context,
title: 'Storage Volume Access', title: 'Storage Volume Access',
content: Text('Please select the $dirDisplayName directory of “$volumeDescription” in the next screen, so that this app can access it and complete your request.'), content: Text('Please select the ${dir.directoryDescription} directory of “${dir.volumeDescription}” in the next screen, so that this app can access it and complete your request.'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@ -46,11 +46,30 @@ mixin PermissionAwareMixin {
// abort if the user cancels in Flutter // abort if the user cancels in Flutter
if (confirmed == null || !confirmed) return false; if (confirmed == null || !confirmed) return false;
final granted = await AndroidFileService.requestVolumeAccess(volumePath); final granted = await AndroidFileService.requestVolumeAccess(dir.volumePath);
if (!granted) { if (!granted) {
// abort if the user denies access from the native dialog // abort if the user denies access from the native dialog
return false; return false;
} }
} }
} }
Future<bool> showRestrictedDirectoryDialog(BuildContext context, VolumeRelativeDirectory dir) {
return showDialog<bool>(
context: context,
builder: (context) {
return AvesDialog(
context: context,
title: 'Restricted Access',
content: Text('This app is not allowed to modify files in the ${dir.directoryDescription} directory of “${dir.volumeDescription}”.\n\nPlease use a pre-installed file manager or gallery app to move the items to another directory.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('OK'.toUpperCase()),
),
],
);
},
);
}
} }

View file

@ -74,7 +74,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
focusNode: _nameFieldFocusNode, focusNode: _nameFieldFocusNode,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Album name', labelText: 'Album name',
helperText: exists ? 'Album already exists' : '', helperText: exists ? 'Directory already exists' : '',
), ),
autofocus: _allVolumes.length == 1, autofocus: _allVolumes.length == 1,
onChanged: (_) => _validate(), onChanged: (_) => _validate(),

View file

@ -47,7 +47,7 @@ class _RenameAlbumDialogState extends State<RenameAlbumDialog> {
controller: _nameController, controller: _nameController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'New name', labelText: 'New name',
helperText: exists ? 'Album already exists' : '', helperText: exists ? 'Directory already exists' : '',
), ),
autofocus: true, autofocus: true,
onChanged: (_) => _validate(), onChanged: (_) => _validate(),