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.
This commit is contained in:
Elias Projahn 2020-04-11 21:59:23 +02:00
parent febcf29cf1
commit e9f0bd03e7
9 changed files with 204 additions and 281 deletions

View file

@ -38,7 +38,7 @@ android {
defaultConfig { defaultConfig {
applicationId "de.johrpan.musicus" applicationId "de.johrpan.musicus"
minSdkVersion 16 minSdkVersion 21
targetSdkVersion 29 targetSdkVersion 29
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName

View file

@ -1,8 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="de.johrpan.musicus"> package="de.johrpan.musicus">
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application <application
android:name="io.flutter.app.FlutterApplication" android:name="io.flutter.app.FlutterApplication"
android:label="Musicus" android:label="Musicus"

View file

@ -1,39 +1,117 @@
package de.johrpan.musicus package de.johrpan.musicus
import androidx.annotation.NonNull; import android.app.Activity
import androidx.core.content.ContextCompat import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant import io.flutter.plugins.GeneratedPluginRegistrant
class Document(private val id: String, private val name: String, private val parentId: String?, private val isDirectory: Boolean) {
fun toMap(): Map<String, Any?> {
return mapOf(
"id" to id,
"name" to name,
"parentId" to parentId,
"isDirectory" to isDirectory
)
}
}
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val CHANNEL = "de.johrpan.musicus/platform" private val CHANNEL = "de.johrpan.musicus/platform"
private val AODT_REQUEST = 0
private var aodtResult: MethodChannel.Result? = null
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
GeneratedPluginRegistrant.registerWith(flutterEngine) GeneratedPluginRegistrant.registerWith(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
if (call.method == "getStorageRoots") { if (call.method == "openTree") {
result.success(getStorageRoots()) aodtResult = result
// We will get the result within onActivityResult
openTree()
} else if (call.method == "getChildren") {
val treeUri = Uri.parse(call.argument<String>("treeUri"))
val parentId = call.argument<String>("parentId")
val children = getChildren(treeUri, parentId)
result.success(children.map { it.toMap() })
} else { } else {
result.notImplemented() result.notImplemented()
} }
} }
} }
private fun getStorageRoots(): ArrayList<String> { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val result = ArrayList<String>() super.onActivityResult(requestCode, resultCode, data)
ContextCompat.getExternalFilesDirs(this, null).forEach { if (requestCode == AODT_REQUEST) {
val path = it.absolutePath; if (resultCode == Activity.RESULT_OK && data?.data != null) {
val index = path.lastIndexOf("/Android/data/") // Drop all old URIs
contentResolver.persistedUriPermissions.forEach {
contentResolver.releasePersistableUriPermission(it.uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
if (index > 0) { // We already checked for null
result.add(path.substring(0, index)) 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<Document> {
val realParentId = parentId ?: DocumentsContract.getTreeDocumentId(treeUri)
val children = ArrayList<Document>()
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
} }
} }

View file

@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'backend.dart'; import 'backend.dart';
import 'screens/home.dart'; import 'screens/home.dart';
import 'selectors/files.dart';
import 'widgets/player_bar.dart'; import 'widgets/player_bar.dart';
class App extends StatelessWidget { class App extends StatelessWidget {
@ -34,37 +33,6 @@ class App extends StatelessWidget {
return Material( return Material(
color: Theme.of(context).scaffoldBackgroundColor, color: Theme.of(context).scaffoldBackgroundColor,
); );
} else if (backend.status == BackendStatus.needsPermissions) {
return Material(
color: Theme.of(context).scaffoldBackgroundColor,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
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) { } else if (backend.status == BackendStatus.setup) {
return Material( return Material(
color: Theme.of(context).scaffoldBackgroundColor, color: Theme.of(context).scaffoldBackgroundColor,
@ -82,20 +50,8 @@ class App extends StatelessWidget {
ListTile( ListTile(
leading: const Icon(Icons.folder_open), leading: const Icon(Icons.folder_open),
title: Text('Choose path'), title: Text('Choose path'),
onTap: () async { onTap: () {
final path = await Navigator.push<String>( backend.chooseMusicLibraryUri();
context,
MaterialPageRoute(
builder: (context) => FilesSelector(
mode: FilesSelectorMode.directory,
),
fullscreenDialog: true,
),
);
if (path != null) {
backend.setMusicLibraryPath(path);
}
}, },
), ),
], ],

View file

@ -1,5 +1,5 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -7,7 +7,6 @@ import 'database.dart';
enum BackendStatus { enum BackendStatus {
loading, loading,
needsPermissions,
setup, setup,
ready, ready,
} }
@ -27,15 +26,15 @@ class Backend extends StatefulWidget {
} }
class BackendState extends State<Backend> { class BackendState extends State<Backend> {
final _permissionHandler = PermissionHandler(); static const _platform = MethodChannel('de.johrpan.musicus/platform');
final playerActive = BehaviorSubject.seeded(false); final playerActive = BehaviorSubject.seeded(false);
final playing = BehaviorSubject.seeded(false); final playing = BehaviorSubject.seeded(false);
final position = BehaviorSubject.seeded(0.0); final position = BehaviorSubject.seeded(0.0);
Database db;
BackendStatus status = BackendStatus.loading; BackendStatus status = BackendStatus.loading;
String musicLibraryPath; Database db;
String musicLibraryUri;
SharedPreferences _shPref; SharedPreferences _shPref;
@ -54,25 +53,15 @@ class BackendState extends State<Backend> {
} }
Future<void> _load() async { Future<void> _load() async {
_shPref = await SharedPreferences.getInstance();
musicLibraryPath = _shPref.getString('musicLibraryPath');
db = Database('musicus.sqlite'); db = Database('musicus.sqlite');
_shPref = await SharedPreferences.getInstance();
final permissionStatus = musicLibraryUri = _shPref.getString('musicLibraryUri');
await _permissionHandler.checkPermissionStatus(PermissionGroup.storage);
_loadMusicLibrary();
if (permissionStatus != PermissionStatus.granted) {
setState(() {
status = BackendStatus.needsPermissions;
});
} else {
await _loadMusicLibrary();
}
} }
Future<void> _loadMusicLibrary() async { Future<void> _loadMusicLibrary() async {
if (musicLibraryPath == null) { if (musicLibraryUri == null) {
setState(() { setState(() {
status = BackendStatus.setup; status = BackendStatus.setup;
}); });
@ -83,26 +72,19 @@ class BackendState extends State<Backend> {
} }
} }
Future<void> requestPermissions() async { Future<void> chooseMusicLibraryUri() async {
final result = final uri = await _platform.invokeMethod<String>('openTree');
await _permissionHandler.requestPermissions([PermissionGroup.storage]);
if (result[PermissionGroup.storage] == PermissionStatus.granted) { if (uri != null) {
_loadMusicLibrary(); musicLibraryUri = uri;
await _shPref.setString('musicLibraryUri', uri);
setState(() {
status = BackendStatus.loading;
});
await _loadMusicLibrary();
} }
} }
Future<void> openAppSettings() => _permissionHandler.openAppSettings();
Future<void> setMusicLibraryPath(String path) async {
musicLibraryPath = path;
await _shPref.setString('musicLibraryPath', path);
setState(() {
status = BackendStatus.loading;
});
await _loadMusicLibrary();
}
void startPlayer() { void startPlayer() {
playerActive.add(true); playerActive.add(true);
} }

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../backend.dart'; import '../backend.dart';
import '../database.dart'; import '../database.dart';
@ -7,6 +6,7 @@ import '../selectors/files.dart';
import '../selectors/recording.dart'; import '../selectors/recording.dart';
import '../widgets/recording_tile.dart'; import '../widgets/recording_tile.dart';
// TODO: Update for storage access framework.
class TrackModel { class TrackModel {
String path; String path;
@ -58,22 +58,9 @@ class _TracksEditorState extends State<TracksEditor> {
final Set<String> paths = await Navigator.push( final Set<String> paths = await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => FilesSelector( builder: (context) => FilesSelector(),
baseDirectory: backend.musicLibraryPath,
),
), ),
); );
if (paths != null) {
setState(() {
for (final path in paths) {
tracks.add(TrackModel(p.relative(
path,
from: backend.musicLibraryPath,
)));
}
});
}
}, },
), ),
), ),

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../backend.dart'; import '../backend.dart';
import '../selectors/files.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
@override @override
@ -17,21 +16,9 @@ class SettingsScreen extends StatelessWidget {
ListTile( ListTile(
leading: Icon(Icons.library_music), leading: Icon(Icons.library_music),
title: Text('Music library path'), title: Text('Music library path'),
subtitle: Text(backend.musicLibraryPath), subtitle: Text(backend.musicLibraryUri),
onTap: () async { onTap: () {
final path = await Navigator.push<String>( backend.chooseMusicLibraryUri();
context,
MaterialPageRoute(
builder: (context) => FilesSelector(
mode: FilesSelectorMode.directory,
),
fullscreenDialog: true,
),
);
if (path != null) {
backend.setMusicLibraryPath(path);
}
}, },
), ),
], ],

View file

@ -1,23 +1,22 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:path/path.dart' as path;
enum FilesSelectorMode { import '../backend.dart';
files,
directory, class Document {
final String id;
final String name;
final String parent;
final bool isDirectory;
Document.fromMap(Map<dynamic, dynamic> map)
: id = map['id'],
name = map['name'],
parent = map['parent'],
isDirectory = map['isDirectory'];
} }
class FilesSelector extends StatefulWidget { class FilesSelector extends StatefulWidget {
final FilesSelectorMode mode;
final String baseDirectory;
FilesSelector({
this.mode = FilesSelectorMode.files,
this.baseDirectory,
});
@override @override
_FilesSelectorState createState() => _FilesSelectorState(); _FilesSelectorState createState() => _FilesSelectorState();
} }
@ -25,104 +24,21 @@ class FilesSelector extends StatefulWidget {
class _FilesSelectorState extends State<FilesSelector> { class _FilesSelectorState extends State<FilesSelector> {
static const platform = MethodChannel('de.johrpan.musicus/platform'); static const platform = MethodChannel('de.johrpan.musicus/platform');
Directory baseDirectory; BackendState backend;
List<Directory> storageRoots; List<Document> history = [];
List<Directory> directories = []; List<Document> children = [];
List<FileSystemEntity> contents = []; Set<String> selectedIds = {};
Set<String> selectedPaths = {};
@override @override
void initState() { void didChangeDependencies() {
super.initState(); super.didChangeDependencies();
if (widget.baseDirectory == null) { backend = Backend.of(context);
platform.invokeListMethod<String>('getStorageRoots').then((sr) { loadChildren();
setState(() {
storageRoots = sr.map((path) => Directory(path)).toList();
});
});
} else {
baseDirectory = Directory(widget.baseDirectory);
openDirectory(baseDirectory);
}
} }
@override @override
Widget build(BuildContext context) { 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( return WillPopScope(
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
@ -135,14 +51,9 @@ class _FilesSelectorState extends State<FilesSelector> {
), ),
actions: <Widget>[ actions: <Widget>[
FlatButton( FlatButton(
child: Text( child: Text('DONE'),
widget.mode == FilesSelectorMode.files ? 'DONE' : 'SELECT'),
onPressed: () { onPressed: () {
Navigator.pop( Navigator.pop(context, selectedIds);
context,
widget.mode == FilesSelectorMode.files
? selectedPaths
: directories.last?.path);
}, },
), ),
], ],
@ -152,71 +63,96 @@ class _FilesSelectorState extends State<FilesSelector> {
Material( Material(
elevation: 2.0, elevation: 2.0,
child: ListTile( child: ListTile(
leading: directories.isNotEmpty leading: IconButton(
? IconButton( icon: const Icon(Icons.arrow_upward),
icon: const Icon(Icons.arrow_upward), onPressed: history.isNotEmpty ? up : null,
onPressed: up, ),
) title: Text(
: null, history.isNotEmpty ? history.last.name : 'Music library'),
title: Text(titleText),
), ),
), ),
Expanded( 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: () { onWillPop: () => Future.value(up()),
if (directories.isNotEmpty) {
up();
return Future.value(false);
} else {
return Future.value(true);
}
},
); );
} }
Future<void> openDirectory(Directory directory) async { Future<void> loadChildren() async {
setState(() { setState(() {
contents.clear(); children = [];
}); });
final fses = await directory.list().toList(); final childrenMaps = await platform.invokeListMethod<Map<dynamic, dynamic>>(
fses.sort((fse1, fse2) { 'getChildren',
int compareBasenames() => {
path.basename(fse1.path).compareTo(path.basename(fse2.path)); 'treeUri': backend.musicLibraryUri,
'parentId': history.isNotEmpty ? history.last.id : null,
},
);
if (fse1 is Directory) { final newChildren = childrenMaps.map((m) => Document.fromMap(m)).toList();
if (fse2 is Directory) { newChildren.sort((d1, d2) {
return compareBasenames(); if (d1.isDirectory != d2.isDirectory) {
} else { return d1.isDirectory ? -1 : 1;
return -1;
}
} else if (fse2 is Directory) {
return 1;
} else { } else {
return compareBasenames(); return d1.name.compareTo(d2.name);
} }
}); });
setState(() { setState(() {
contents = fses; children = newChildren;
}); });
} }
void up() { bool up() {
if (directories.isNotEmpty) { if (history.isNotEmpty) {
setState(() { setState(() {
directories.removeLast(); history.removeLast();
}); });
if (directories.isNotEmpty) { loadChildren();
openDirectory(directories.last);
} else if (baseDirectory != null) { return false;
openDirectory(baseDirectory); } else {
} return true;
} }
} }
} }

View file

@ -13,7 +13,6 @@ dependencies:
sdk: flutter sdk: flutter
moor_flutter: moor_flutter:
path: path:
permission_handler:
rxdart: rxdart:
shared_preferences: shared_preferences: