From e9f0bd03e79e90f121648a00408ff52caf66d380 Mon Sep 17 00:00:00 2001 From: Elias Projahn Date: Sat, 11 Apr 2020 21:59:23 +0200 Subject: [PATCH] Use the storage access framework Everything related to file system access has been rewritten to make use of the storage access framework. This means that the WRITE_EXTERNAL_STORAGE is no longer needed. Because of that, the dependency on permission_handler could be dropped and all code related to permission handling has been removed. To be able to open a whole document tree, the minSdkVersion was bumped to 21. Finally the file selector was rewritten using custom platform dependent code. --- android/app/build.gradle | 2 +- android/app/src/main/AndroidManifest.xml | 2 - .../kotlin/de/johrpan/musicus/MainActivity.kt | 102 +++++++- lib/app.dart | 48 +--- lib/backend.dart | 54 ++-- lib/editors/tracks.dart | 17 +- lib/screens/settings.dart | 19 +- lib/selectors/files.dart | 240 +++++++----------- pubspec.yaml | 1 - 9 files changed, 204 insertions(+), 281 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 84d731c..b0d2158 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -38,7 +38,7 @@ android { defaultConfig { applicationId "de.johrpan.musicus" - minSdkVersion 16 + minSdkVersion 21 targetSdkVersion 29 versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a20ece3..8ec7429 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,6 @@ - - { + return mapOf( + "id" to id, + "name" to name, + "parentId" to parentId, + "isDirectory" to isDirectory + ) + } +} + class MainActivity : FlutterActivity() { private val CHANNEL = "de.johrpan.musicus/platform" + private val AODT_REQUEST = 0 + + private var aodtResult: MethodChannel.Result? = null override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { GeneratedPluginRegistrant.registerWith(flutterEngine) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> - if (call.method == "getStorageRoots") { - result.success(getStorageRoots()) + if (call.method == "openTree") { + aodtResult = result + // We will get the result within onActivityResult + openTree() + } else if (call.method == "getChildren") { + val treeUri = Uri.parse(call.argument("treeUri")) + val parentId = call.argument("parentId") + val children = getChildren(treeUri, parentId) + result.success(children.map { it.toMap() }) } else { result.notImplemented() } } } - private fun getStorageRoots(): ArrayList { - val result = ArrayList() + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) - ContextCompat.getExternalFilesDirs(this, null).forEach { - val path = it.absolutePath; - val index = path.lastIndexOf("/Android/data/") + if (requestCode == AODT_REQUEST) { + if (resultCode == Activity.RESULT_OK && data?.data != null) { + // Drop all old URIs + contentResolver.persistedUriPermissions.forEach { + contentResolver.releasePersistableUriPermission(it.uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } - if (index > 0) { - result.add(path.substring(0, index)) + // We already checked for null + val uri = data.data!! + contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + + aodtResult?.success(uri.toString()) + } else { + aodtResult?.success(null) } } + } - return result + /** + * Open a document tree using the storage access framework + * + * The result is handled within [onActivityResult] + */ + private fun openTree() { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + + startActivityForResult(intent, AODT_REQUEST) + } + + /** + * List children of a directory + * + * @param treeUri The treeUri from the ACTION_OPEN_DOCUMENT_TREE request + * @param parentId Document ID of the parent directory or null for the top level directory + * @return List of directories and files within the directory + */ + private fun getChildren(treeUri: Uri, parentId: String?): List { + val realParentId = parentId ?: DocumentsContract.getTreeDocumentId(treeUri) + val children = ArrayList() + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, realParentId) + + val cursor = contentResolver.query( + childrenUri, + arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE), + null, null, null) + + if (cursor != null) { + while (cursor.moveToNext()) { + val id = cursor.getString(0) + val name = cursor.getString(1) + val isDirectory = cursor.getString(2) == DocumentsContract.Document.MIME_TYPE_DIR + + // Use parentId here to let the consumer know that we are at the top level. + children.add(Document(id, name, parentId, isDirectory)) + } + + cursor.close() + } + + return children } } diff --git a/lib/app.dart b/lib/app.dart index c021ce2..6936c52 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'backend.dart'; import 'screens/home.dart'; -import 'selectors/files.dart'; import 'widgets/player_bar.dart'; class App extends StatelessWidget { @@ -34,37 +33,6 @@ class App extends StatelessWidget { return Material( color: Theme.of(context).scaffoldBackgroundColor, ); - } else if (backend.status == BackendStatus.needsPermissions) { - return Material( - color: Theme.of(context).scaffoldBackgroundColor, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Musicus needs permissions\nto access your files.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headline6, - ), - SizedBox( - height: 16.0, - ), - ListTile( - leading: const Icon(Icons.done), - title: Text('Grant permissions'), - onTap: () { - backend.requestPermissions(); - }, - ), - ListTile( - leading: const Icon(Icons.settings), - title: Text('Open system\'s app settings'), - onTap: () { - backend.openAppSettings(); - }, - ), - ], - ), - ); } else if (backend.status == BackendStatus.setup) { return Material( color: Theme.of(context).scaffoldBackgroundColor, @@ -82,20 +50,8 @@ class App extends StatelessWidget { ListTile( leading: const Icon(Icons.folder_open), title: Text('Choose path'), - onTap: () async { - final path = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => FilesSelector( - mode: FilesSelectorMode.directory, - ), - fullscreenDialog: true, - ), - ); - - if (path != null) { - backend.setMusicLibraryPath(path); - } + onTap: () { + backend.chooseMusicLibraryUri(); }, ), ], diff --git a/lib/backend.dart b/lib/backend.dart index f75293f..6906ae6 100644 --- a/lib/backend.dart +++ b/lib/backend.dart @@ -1,5 +1,5 @@ +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:rxdart/rxdart.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -7,7 +7,6 @@ import 'database.dart'; enum BackendStatus { loading, - needsPermissions, setup, ready, } @@ -27,15 +26,15 @@ class Backend extends StatefulWidget { } class BackendState extends State { - final _permissionHandler = PermissionHandler(); + static const _platform = MethodChannel('de.johrpan.musicus/platform'); final playerActive = BehaviorSubject.seeded(false); final playing = BehaviorSubject.seeded(false); final position = BehaviorSubject.seeded(0.0); - Database db; BackendStatus status = BackendStatus.loading; - String musicLibraryPath; + Database db; + String musicLibraryUri; SharedPreferences _shPref; @@ -54,25 +53,15 @@ class BackendState extends State { } Future _load() async { - _shPref = await SharedPreferences.getInstance(); - musicLibraryPath = _shPref.getString('musicLibraryPath'); - db = Database('musicus.sqlite'); - - final permissionStatus = - await _permissionHandler.checkPermissionStatus(PermissionGroup.storage); - - if (permissionStatus != PermissionStatus.granted) { - setState(() { - status = BackendStatus.needsPermissions; - }); - } else { - await _loadMusicLibrary(); - } + _shPref = await SharedPreferences.getInstance(); + musicLibraryUri = _shPref.getString('musicLibraryUri'); + + _loadMusicLibrary(); } Future _loadMusicLibrary() async { - if (musicLibraryPath == null) { + if (musicLibraryUri == null) { setState(() { status = BackendStatus.setup; }); @@ -83,26 +72,19 @@ class BackendState extends State { } } - Future requestPermissions() async { - final result = - await _permissionHandler.requestPermissions([PermissionGroup.storage]); + Future chooseMusicLibraryUri() async { + final uri = await _platform.invokeMethod('openTree'); - if (result[PermissionGroup.storage] == PermissionStatus.granted) { - _loadMusicLibrary(); + if (uri != null) { + musicLibraryUri = uri; + await _shPref.setString('musicLibraryUri', uri); + setState(() { + status = BackendStatus.loading; + }); + await _loadMusicLibrary(); } } - Future openAppSettings() => _permissionHandler.openAppSettings(); - - Future setMusicLibraryPath(String path) async { - musicLibraryPath = path; - await _shPref.setString('musicLibraryPath', path); - setState(() { - status = BackendStatus.loading; - }); - await _loadMusicLibrary(); - } - void startPlayer() { playerActive.add(true); } diff --git a/lib/editors/tracks.dart b/lib/editors/tracks.dart index 60fb602..efb5dc8 100644 --- a/lib/editors/tracks.dart +++ b/lib/editors/tracks.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:path/path.dart' as p; import '../backend.dart'; import '../database.dart'; @@ -7,6 +6,7 @@ import '../selectors/files.dart'; import '../selectors/recording.dart'; import '../widgets/recording_tile.dart'; +// TODO: Update for storage access framework. class TrackModel { String path; @@ -58,22 +58,9 @@ class _TracksEditorState extends State { final Set paths = await Navigator.push( context, MaterialPageRoute( - builder: (context) => FilesSelector( - baseDirectory: backend.musicLibraryPath, - ), + builder: (context) => FilesSelector(), ), ); - - if (paths != null) { - setState(() { - for (final path in paths) { - tracks.add(TrackModel(p.relative( - path, - from: backend.musicLibraryPath, - ))); - } - }); - } }, ), ), diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 3410ca4..4afcb5d 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import '../backend.dart'; -import '../selectors/files.dart'; class SettingsScreen extends StatelessWidget { @override @@ -17,21 +16,9 @@ class SettingsScreen extends StatelessWidget { ListTile( leading: Icon(Icons.library_music), title: Text('Music library path'), - subtitle: Text(backend.musicLibraryPath), - onTap: () async { - final path = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => FilesSelector( - mode: FilesSelectorMode.directory, - ), - fullscreenDialog: true, - ), - ); - - if (path != null) { - backend.setMusicLibraryPath(path); - } + subtitle: Text(backend.musicLibraryUri), + onTap: () { + backend.chooseMusicLibraryUri(); }, ), ], diff --git a/lib/selectors/files.dart b/lib/selectors/files.dart index 91f850b..c4cb09c 100644 --- a/lib/selectors/files.dart +++ b/lib/selectors/files.dart @@ -1,23 +1,22 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:path/path.dart' as path; -enum FilesSelectorMode { - files, - directory, +import '../backend.dart'; + +class Document { + final String id; + final String name; + final String parent; + final bool isDirectory; + + Document.fromMap(Map map) + : id = map['id'], + name = map['name'], + parent = map['parent'], + isDirectory = map['isDirectory']; } class FilesSelector extends StatefulWidget { - final FilesSelectorMode mode; - final String baseDirectory; - - FilesSelector({ - this.mode = FilesSelectorMode.files, - this.baseDirectory, - }); - @override _FilesSelectorState createState() => _FilesSelectorState(); } @@ -25,104 +24,21 @@ class FilesSelector extends StatefulWidget { class _FilesSelectorState extends State { static const platform = MethodChannel('de.johrpan.musicus/platform'); - Directory baseDirectory; - List storageRoots; - List directories = []; - List contents = []; - Set selectedPaths = {}; + BackendState backend; + List history = []; + List children = []; + Set selectedIds = {}; @override - void initState() { - super.initState(); + void didChangeDependencies() { + super.didChangeDependencies(); - if (widget.baseDirectory == null) { - platform.invokeListMethod('getStorageRoots').then((sr) { - setState(() { - storageRoots = sr.map((path) => Directory(path)).toList(); - }); - }); - } else { - baseDirectory = Directory(widget.baseDirectory); - openDirectory(baseDirectory); - } + backend = Backend.of(context); + loadChildren(); } @override Widget build(BuildContext context) { - String titleText; - Widget body; - - if (directories.isEmpty && storageRoots != null) { - titleText = 'Storage devices'; - body = ListView( - children: storageRoots - .map((dir) => ListTile( - leading: const Icon(Icons.storage), - title: Text(dir.path), - onTap: () { - setState(() { - directories.add(dir); - }); - - openDirectory(dir); - }, - )) - .toList(), - ); - } else if (contents != null) { - if (directories.isEmpty) { - titleText = 'Base directory'; - } else { - titleText = path.basename(directories.last.path); - } - - body = ListView( - children: contents.map((fse) { - Widget result; - - if (fse is Directory) { - result = ListTile( - leading: const Icon(Icons.folder), - title: Text(path.basename(fse.path)), - onTap: () { - setState(() { - directories.add(fse); - }); - - openDirectory(fse); - }, - ); - } else if (fse is File) { - if (widget.mode == FilesSelectorMode.files) { - result = CheckboxListTile( - value: selectedPaths.contains(fse.path), - secondary: Icon(Icons.insert_drive_file), - title: Text(path.basename(fse.path)), - onChanged: (selected) { - setState(() { - if (selected) { - selectedPaths.add(fse.path); - } else { - selectedPaths.remove(fse.path); - } - }); - }, - ); - } else { - result = ListTile( - leading: const Icon(Icons.insert_drive_file), - title: Text(path.basename(fse.path)), - ); - } - } - - return result; - }).toList(), - ); - } else { - body = Container(); - } - return WillPopScope( child: Scaffold( appBar: AppBar( @@ -135,14 +51,9 @@ class _FilesSelectorState extends State { ), actions: [ FlatButton( - child: Text( - widget.mode == FilesSelectorMode.files ? 'DONE' : 'SELECT'), + child: Text('DONE'), onPressed: () { - Navigator.pop( - context, - widget.mode == FilesSelectorMode.files - ? selectedPaths - : directories.last?.path); + Navigator.pop(context, selectedIds); }, ), ], @@ -152,71 +63,96 @@ class _FilesSelectorState extends State { Material( elevation: 2.0, child: ListTile( - leading: directories.isNotEmpty - ? IconButton( - icon: const Icon(Icons.arrow_upward), - onPressed: up, - ) - : null, - title: Text(titleText), + leading: IconButton( + icon: const Icon(Icons.arrow_upward), + onPressed: history.isNotEmpty ? up : null, + ), + title: Text( + history.isNotEmpty ? history.last.name : 'Music library'), ), ), Expanded( - child: body, + child: ListView.builder( + itemCount: children.length, + itemBuilder: (context, index) { + final document = children[index]; + + if (document.isDirectory) { + return ListTile( + leading: const Icon(Icons.folder), + title: Text(document.name), + onTap: () { + setState(() { + history.add(document); + }); + loadChildren(); + }, + ); + } else { + return CheckboxListTile( + controlAffinity: ListTileControlAffinity.trailing, + secondary: const Icon(Icons.insert_drive_file), + title: Text(document.name), + value: selectedIds.contains(document.id), + onChanged: (selected) { + setState(() { + if (selected) { + selectedIds.add(document.id); + } else { + selectedIds.remove(document.id); + } + }); + }, + ); + } + }, + ), ), ], ), ), - onWillPop: () { - if (directories.isNotEmpty) { - up(); - return Future.value(false); - } else { - return Future.value(true); - } - }, + onWillPop: () => Future.value(up()), ); } - Future openDirectory(Directory directory) async { + Future loadChildren() async { setState(() { - contents.clear(); + children = []; }); - final fses = await directory.list().toList(); - fses.sort((fse1, fse2) { - int compareBasenames() => - path.basename(fse1.path).compareTo(path.basename(fse2.path)); + final childrenMaps = await platform.invokeListMethod>( + 'getChildren', + { + 'treeUri': backend.musicLibraryUri, + 'parentId': history.isNotEmpty ? history.last.id : null, + }, + ); - if (fse1 is Directory) { - if (fse2 is Directory) { - return compareBasenames(); - } else { - return -1; - } - } else if (fse2 is Directory) { - return 1; + final newChildren = childrenMaps.map((m) => Document.fromMap(m)).toList(); + newChildren.sort((d1, d2) { + if (d1.isDirectory != d2.isDirectory) { + return d1.isDirectory ? -1 : 1; } else { - return compareBasenames(); + return d1.name.compareTo(d2.name); } }); setState(() { - contents = fses; + children = newChildren; }); } - void up() { - if (directories.isNotEmpty) { + bool up() { + if (history.isNotEmpty) { setState(() { - directories.removeLast(); + history.removeLast(); }); - if (directories.isNotEmpty) { - openDirectory(directories.last); - } else if (baseDirectory != null) { - openDirectory(baseDirectory); - } + loadChildren(); + + return false; + } else { + return true; } } } diff --git a/pubspec.yaml b/pubspec.yaml index 37835fa..56cc286 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,7 +13,6 @@ dependencies: sdk: flutter moor_flutter: path: - permission_handler: rxdart: shared_preferences: