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 {
applicationId "de.johrpan.musicus"
minSdkVersion 16
minSdkVersion 21
targetSdkVersion 29
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName

View file

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

View file

@ -1,39 +1,117 @@
package de.johrpan.musicus
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat
import android.app.Activity
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.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
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() {
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<String>("treeUri"))
val parentId = call.argument<String>("parentId")
val children = getChildren(treeUri, parentId)
result.success(children.map { it.toMap() })
} else {
result.notImplemented()
}
}
}
private fun getStorageRoots(): ArrayList<String> {
val result = ArrayList<String>()
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<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 '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: <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) {
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<String>(
context,
MaterialPageRoute(
builder: (context) => FilesSelector(
mode: FilesSelectorMode.directory,
),
fullscreenDialog: true,
),
);
if (path != null) {
backend.setMusicLibraryPath(path);
}
onTap: () {
backend.chooseMusicLibraryUri();
},
),
],

View file

@ -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<Backend> {
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<Backend> {
}
Future<void> _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<void> _loadMusicLibrary() async {
if (musicLibraryPath == null) {
if (musicLibraryUri == null) {
setState(() {
status = BackendStatus.setup;
});
@ -83,26 +72,19 @@ class BackendState extends State<Backend> {
}
}
Future<void> requestPermissions() async {
final result =
await _permissionHandler.requestPermissions([PermissionGroup.storage]);
Future<void> chooseMusicLibraryUri() async {
final uri = await _platform.invokeMethod<String>('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<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() {
playerActive.add(true);
}

View file

@ -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<TracksEditor> {
final Set<String> 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,
)));
}
});
}
},
),
),

View file

@ -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<String>(
context,
MaterialPageRoute(
builder: (context) => FilesSelector(
mode: FilesSelectorMode.directory,
),
fullscreenDialog: true,
),
);
if (path != null) {
backend.setMusicLibraryPath(path);
}
subtitle: Text(backend.musicLibraryUri),
onTap: () {
backend.chooseMusicLibraryUri();
},
),
],

View file

@ -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<dynamic, dynamic> 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<FilesSelector> {
static const platform = MethodChannel('de.johrpan.musicus/platform');
Directory baseDirectory;
List<Directory> storageRoots;
List<Directory> directories = [];
List<FileSystemEntity> contents = [];
Set<String> selectedPaths = {};
BackendState backend;
List<Document> history = [];
List<Document> children = [];
Set<String> selectedIds = {};
@override
void initState() {
super.initState();
void didChangeDependencies() {
super.didChangeDependencies();
if (widget.baseDirectory == null) {
platform.invokeListMethod<String>('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<FilesSelector> {
),
actions: <Widget>[
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<FilesSelector> {
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<void> openDirectory(Directory directory) async {
Future<void> 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<Map<dynamic, dynamic>>(
'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;
}
}
}

View file

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