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

@ -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;
}
}
}