android 11: improved handling and feedback for restricted directories
This commit is contained in:
parent
7252346444
commit
9173ee9121
12 changed files with 181 additions and 54 deletions
|
@ -14,10 +14,6 @@
|
|||
https://developer.android.com/preview/privacy/storage#media-file-access
|
||||
- raw path access:
|
||||
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" />
|
||||
|
|
|
@ -325,7 +325,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
if (xmpMeta.doesPropertyExist(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 }
|
||||
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 }
|
||||
if (!metadataMap.containsKey(KEY_XMP_TITLE_DESCRIPTION)) {
|
||||
|
@ -350,7 +350,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
// XMP fallback to IPTC
|
||||
if (!metadataMap.containsKey(KEY_XMP_SUBJECTS)) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
"getFreeSpace" -> safe(call, result, ::getFreeSpace)
|
||||
"getGrantedDirectories" -> safe(call, result, ::getGrantedDirectories)
|
||||
"getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories)
|
||||
"getRestrictedDirectories" -> safe(call, result, ::getRestrictedDirectories)
|
||||
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
|
||||
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
|
||||
else -> result.notImplemented()
|
||||
|
@ -104,8 +105,11 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
val dirs = PermissionManager.getInaccessibleDirectories(context, dirPaths)
|
||||
result.success(dirs)
|
||||
result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths))
|
||||
}
|
||||
|
||||
private fun getRestrictedDirectories(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(PermissionManager.getRestrictedDirectories(context))
|
||||
}
|
||||
|
||||
private fun revokeDirectoryAccess(call: MethodCall, result: MethodChannel.Result) {
|
||||
|
|
|
@ -5,12 +5,14 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
object PermissionManager {
|
||||
private val LOG_TAG = LogUtils.createTag(PermissionManager::class.java)
|
||||
|
@ -66,10 +68,21 @@ object PermissionManager {
|
|||
val dirSet = dirsPerVolume[volumePath] ?: HashSet()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// request primary directory on volume from Android R
|
||||
segments.relativeDir?.apply {
|
||||
val primaryDir = split(File.separator).firstOrNull { it.isNotEmpty() }
|
||||
val relativeDir = segments.relativeDir
|
||||
if (relativeDir != null) {
|
||||
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 {
|
||||
// request volume root until Android Q
|
||||
dirSet.add("")
|
||||
|
@ -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 {
|
||||
return StorageUtils.convertDirPathToTreeUri(context, path)?.let {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
|
|
@ -23,34 +23,36 @@ mixin AlbumMixin on SourceBase {
|
|||
|
||||
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
|
||||
|
||||
String getUniqueAlbumName(String album) {
|
||||
final otherAlbums = _directories.where((item) => item != album);
|
||||
final parts = album.split(separator);
|
||||
String getUniqueAlbumName(String dirPath) {
|
||||
String unique(String dirPath, [bool Function(String) test]) {
|
||||
final otherAlbums = _directories.where(test ?? (_) => true).where((item) => item != dirPath);
|
||||
final parts = dirPath.split(separator);
|
||||
var partCount = 0;
|
||||
String testName;
|
||||
do {
|
||||
testName = separator + 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;
|
||||
}
|
||||
|
||||
final volumeRootLength = volume.path.length;
|
||||
if (album.length < volumeRootLength) {
|
||||
// `album` is at the root, without trailing '/'
|
||||
return uniqueName;
|
||||
}
|
||||
final dir = VolumeRelativeDirectory.fromPath(dirPath);
|
||||
if (dir == null) return dirPath;
|
||||
|
||||
final albumRelativePath = album.substring(volumeRootLength);
|
||||
if (uniqueName.length < albumRelativePath.length) {
|
||||
return uniqueName;
|
||||
} else if (volume.isPrimary) {
|
||||
return albumRelativePath;
|
||||
final uniqueNameInDevice = unique(dirPath);
|
||||
final relativeDir = dir.relativeDir;
|
||||
if (relativeDir.isEmpty) return uniqueNameInDevice;
|
||||
|
||||
if (uniqueNameInDevice.length < relativeDir.length) {
|
||||
return uniqueNameInDevice;
|
||||
} 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})';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -52,20 +52,28 @@ class AndroidFileService {
|
|||
return;
|
||||
}
|
||||
|
||||
// returns a list of directories,
|
||||
// each directory is a map with "volumePath", "relativeDir"
|
||||
static Future<List<Map>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
|
||||
static Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{
|
||||
'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) {
|
||||
debugPrint('getInaccessibleDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
}
|
||||
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`
|
||||
static Future<bool> requestVolumeAccess(String volumePath) async {
|
||||
try {
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:aves/services/android_app_service.dart';
|
|||
import 'package:aves/services/android_file_service.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
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}';
|
||||
}
|
||||
|
||||
@immutable
|
||||
class StorageVolume {
|
||||
final String description, path, state;
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -7,8 +7,10 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.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_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/permission_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 {
|
||||
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(
|
||||
context,
|
||||
MaterialPageRoute<String>(
|
||||
|
@ -73,7 +89,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
if (destinationAlbum == null || destinationAlbum.isEmpty) 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;
|
||||
|
||||
|
|
|
@ -87,10 +87,7 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
|
|||
Future<void> onComplete() => _animationController.reverse().then((_) => widget.onDone(processed));
|
||||
opStream.listen(
|
||||
processed.add,
|
||||
onError: (error) {
|
||||
debugPrint('_showOpReport error=$error');
|
||||
onComplete();
|
||||
},
|
||||
onError: (error) => debugPrint('_showOpReport error=$error'),
|
||||
onDone: onComplete,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -10,26 +10,26 @@ mixin PermissionAwareMixin {
|
|||
}
|
||||
|
||||
Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async {
|
||||
final restrictedDirs = await AndroidFileService.getRestrictedDirectories();
|
||||
while (true) {
|
||||
final dirs = await AndroidFileService.getInaccessibleDirectories(albumPaths);
|
||||
if (dirs == null) return false;
|
||||
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 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>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesDialog(
|
||||
context: context,
|
||||
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: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
@ -46,11 +46,30 @@ mixin PermissionAwareMixin {
|
|||
// abort if the user cancels in Flutter
|
||||
if (confirmed == null || !confirmed) return false;
|
||||
|
||||
final granted = await AndroidFileService.requestVolumeAccess(volumePath);
|
||||
final granted = await AndroidFileService.requestVolumeAccess(dir.volumePath);
|
||||
if (!granted) {
|
||||
// abort if the user denies access from the native dialog
|
||||
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()),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -74,7 +74,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
|||
focusNode: _nameFieldFocusNode,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Album name',
|
||||
helperText: exists ? 'Album already exists' : '',
|
||||
helperText: exists ? 'Directory already exists' : '',
|
||||
),
|
||||
autofocus: _allVolumes.length == 1,
|
||||
onChanged: (_) => _validate(),
|
||||
|
|
|
@ -47,7 +47,7 @@ class _RenameAlbumDialogState extends State<RenameAlbumDialog> {
|
|||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'New name',
|
||||
helperText: exists ? 'Album already exists' : '',
|
||||
helperText: exists ? 'Directory already exists' : '',
|
||||
),
|
||||
autofocus: true,
|
||||
onChanged: (_) => _validate(),
|
||||
|
|
Loading…
Reference in a new issue