diff --git a/android/app/src/main/kotlin/de/johrpan/musicus/MainActivity.kt b/android/app/src/main/kotlin/de/johrpan/musicus/MainActivity.kt index ae5cb76..153be69 100644 --- a/android/app/src/main/kotlin/de/johrpan/musicus/MainActivity.kt +++ b/android/app/src/main/kotlin/de/johrpan/musicus/MainActivity.kt @@ -40,6 +40,22 @@ class MainActivity : FlutterActivity() { val parentId = call.argument("parentId") val children = getChildren(treeUri, parentId) result.success(children.map { it.toMap() }) + } else if (call.method == "readFile") { + val treeUri = Uri.parse(call.argument("treeUri")) + val id = call.argument("id")!! + result.success(readFile(treeUri, id)) + } else if (call.method == "readFileByName") { + val treeUri = Uri.parse(call.argument("treeUri")) + val parentId = call.argument("parentId")!! + val fileName = call.argument("fileName")!! + result.success(readFileByName(treeUri, parentId, fileName)) + } else if (call.method == "writeFileByName") { + val treeUri = Uri.parse(call.argument("treeUri")) + val parentId = call.argument("parentId")!! + val fileName = call.argument("fileName")!! + val content = call.argument("content")!! + writeFileByName(treeUri, parentId, fileName, content) + result.success(null) } else { result.notImplemented() } @@ -114,4 +130,104 @@ class MainActivity : FlutterActivity() { return children } + + /** + * Look for a file by name + * + * @param treeUri The treeUri from the ACTION_OPEN_DOCUMENT_TREE request + * @param parentId The directory in which the file is searched for + * @param fileName Name of the file + * @return The URI of the file or null + */ + private fun getUriByName(treeUri: Uri, parentId: String, fileName: String): Uri? { + var uri: Uri? = null + + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentId) + val projection = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME) + + // The file system provider doesn't support a select clause. + val cursor = contentResolver.query(childrenUri, projection, null, null, null) + + if (cursor != null) { + while (cursor.moveToNext()) { + val id = cursor.getString(0) + val name = cursor.getString(1) + + if (name == fileName) { + uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, id) + break + } + } + + cursor.close() + } + + return uri + } + + /** + * Read content of a file + * + * @param treeUri The URI from ACTION_OPEN_DOCUMENT_TREE + * @param id The document ID of the file + * @return File content or null + */ + private fun readFile(treeUri: Uri, id: String): String? { + val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, id) + + // TODO: Handle errors. + val input = contentResolver.openInputStream(uri)!! + val result = input.reader().readText() + input.close() + + return result + } + + /** + * Read content of a file by name + * + * @param treeUri The URI from ACTION_OPEN_DOCUMENT_TREE + * @param parentId Document ID of the parent directory + * @param fileName Name of the file + * @return File content or null + */ + private fun readFileByName(treeUri: Uri, parentId: String, fileName: String): String? { + var uri = getUriByName(treeUri, parentId, fileName) + + return if (uri != null) { + // TODO: Handle errors. + val input = contentResolver.openInputStream(uri)!! + val result = input.reader().readText() + input.close() + + return result + } else { + null + } + } + + /** + * Write to file by name + * + * The file will always have the MIME type application/json. + * + * @param treeUri The URI from ACTION_OPEN_DOCUMENT_TREE + * @param parentId Document ID of the parent directory + * @param fileName Name of the file + * @param content Content to write + * @return File content or null + */ + private fun writeFileByName(treeUri: Uri, parentId: String, fileName: String, content: String) { + var uri = getUriByName(treeUri, parentId, fileName); + + if (uri == null) { + val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentId) + uri = DocumentsContract.createDocument(contentResolver, parentUri, "application/json", fileName) + } + + // TODO: Handle errors. + val output = contentResolver.openOutputStream(uri!!)!!; + output.writer().write(content) + output.close() + } } diff --git a/lib/backend.dart b/lib/backend.dart index b36a4fb..0667b8f 100644 --- a/lib/backend.dart +++ b/lib/backend.dart @@ -11,6 +11,7 @@ import 'package:path_provider/path_provider.dart' as pp; import 'package:shared_preferences/shared_preferences.dart'; import 'database.dart'; +import 'music_library.dart'; import 'player.dart'; // The following code was taken from @@ -86,6 +87,7 @@ class BackendState extends State { BackendStatus status = BackendStatus.loading; Database db; String musicLibraryUri; + MusicLibrary ml; MoorIsolate _moorIsolate; SharedPreferences _shPref; @@ -122,6 +124,8 @@ class BackendState extends State { status = BackendStatus.setup; }); } else { + ml = MusicLibrary(musicLibraryUri); + await ml.load(); setState(() { status = BackendStatus.ready; }); diff --git a/lib/editors/tracks.dart b/lib/editors/tracks.dart index 6b6baaa..f746599 100644 --- a/lib/editors/tracks.dart +++ b/lib/editors/tracks.dart @@ -2,15 +2,15 @@ import 'package:flutter/material.dart'; import '../backend.dart'; import '../database.dart'; +import '../music_library.dart'; import '../selectors/files.dart'; import '../selectors/recording.dart'; import '../widgets/recording_tile.dart'; -// TODO: Update for storage access framework. class TrackModel { - String path; + String fileName; - TrackModel(this.path); + TrackModel(this.fileName); } class TracksEditor extends StatefulWidget { @@ -20,7 +20,8 @@ class TracksEditor extends StatefulWidget { class _TracksEditorState extends State { int recordingId; - List tracks = []; + String parentId; + List trackModels = []; @override Widget build(BuildContext context) { @@ -33,7 +34,22 @@ class _TracksEditorState extends State { FlatButton( child: Text('DONE'), onPressed: () async { - // TODO: Save tracks. + final List tracks = []; + + for (var i = 0; i < trackModels.length; i++) { + final trackModel = trackModels[i]; + + tracks.add(Track( + fileName: trackModel.fileName, + recordingId: recordingId, + index: i, + partIds: [], + )); + } + + backend.ml.addTracks(parentId, tracks); + + Navigator.pop(context); }, ), ], @@ -63,22 +79,27 @@ class _TracksEditorState extends State { ); if (result != null) { - // TODO: Add tracks. + setState(() { + parentId = result.parentId; + for (final document in result.selection) { + trackModels.add(TrackModel(document.name)); + } + }); } }, ), ), ], ), - children: tracks + children: trackModels .map((t) => ListTile( key: Key(t.hashCode.toString()), - title: Text(t.path), + title: Text(t.fileName), trailing: IconButton( icon: const Icon(Icons.delete), onPressed: () { setState(() { - tracks.remove(t); + trackModels.remove(t); }); }, ), @@ -86,9 +107,9 @@ class _TracksEditorState extends State { .toList(), onReorder: (i1, i2) { setState(() { - final track = tracks.removeAt(i1); + final track = trackModels.removeAt(i1); final newIndex = i2 > i1 ? i2 - 1 : i2; - tracks.insert(newIndex, track); + trackModels.insert(newIndex, track); }); }, ), diff --git a/lib/music_library.dart b/lib/music_library.dart new file mode 100644 index 0000000..c03be8d --- /dev/null +++ b/lib/music_library.dart @@ -0,0 +1,155 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +import 'platform.dart'; + +/// Description of a concrete audio file. +/// +/// This gets stored in the folder of the audio file and links the audio file +/// to a recording in the database. +class Track { + /// The name of the file that contains the track's audio. + /// + /// This corresponds to a document ID in terms of the Android Storage Access + /// Framework. + final String fileName; + + /// Index within the list of tracks for the corresponding recording. + final int index; + + /// Of which recording this track is a part of. + final int recordingId; + + /// Which work parts of the recorded work are contained in this track. + final List partIds; + + Track({ + this.fileName, + this.index, + this.recordingId, + this.partIds, + }); + + factory Track.fromJson(Map json) => Track( + fileName: json['fileName'], + index: json['index'], + recordingId: json['recording'], + partIds: List.from(json['parts']), + ); + + Map toJson() => { + 'fileName': fileName, + 'index': index, + 'recording': recordingId, + 'parts': partIds, + }; +} + +/// Representation of all tracked audio files in one folder. +class MusicusFile { + /// Current version of the Musicus file format. + /// + /// If incompatible changes are made, this will be increased by one. + static const currentVersion = 0; + + /// Musicus file format version in use. + /// + /// This will be used in the future, if incompatible changes are made. + final int version; + + /// List of [Track] objects. + final List tracks; + + MusicusFile({ + this.version = currentVersion, + List tracks, + }) : tracks = tracks ?? []; + + factory MusicusFile.fromJson(Map json) => MusicusFile( + version: json['version'], + tracks: json['tracks'] + .map((trackJson) => Track.fromJson(trackJson)) + .toList(growable: true), + ); + + Map toJson() => { + 'version': version, + 'tracks': tracks.map((t) => t.toJson()).toList(), + }; +} + +/// Manager for all available tracks and their representation on disk. +class MusicLibrary { + static const platform = MethodChannel('de.johrpan.musicus/platform'); + + /// URI of the music library folder. + /// + /// This is a tree URI in the terms of the Android Storage Access Framework. + final String treeUri; + + /// Map of all available tracks by recording ID. + final Map> tracks = {}; + + MusicLibrary(this.treeUri); + + /// Load all available tracks. + /// + /// This recursively searches through the whole music library, reads the + /// content of all files called musicus.json and stores all track information + /// that it found. + Future load() async { + // TODO: Consider capping the recursion somewhere. + Future recurse([String parentId]) async { + final children = await Platform.getChildren(treeUri, parentId); + + for (final child in children) { + if (child.isDirectory) { + recurse(child.id); + } else if (child.name == 'musicus.json') { + final content = await Platform.readFile(treeUri, child.id); + final musicusFile = MusicusFile.fromJson(jsonDecode(content)); + for (final track in musicusFile.tracks) { + if (tracks.containsKey(track.recordingId)) { + tracks[track.recordingId].add(track); + } else { + tracks[track.recordingId] = [track]; + } + } + } + } + } + + await recurse(); + } + + /// Add a list of new tracks to the music library. + /// + /// They are stored in this instance and on disk in the directory denoted by + /// [parentId]. + Future addTracks(String parentId, List newTracks) async { + MusicusFile musicusFile; + + final oldContent = + await Platform.readFileByName(treeUri, parentId, 'musicus.json'); + + if (oldContent != null) { + musicusFile = MusicusFile.fromJson(jsonDecode(oldContent)); + } else { + musicusFile = MusicusFile(); + } + + for (final track in newTracks) { + musicusFile.tracks.add(track); + + if (tracks.containsKey(track.recordingId)) { + tracks[track.recordingId].add(track); + } else { + tracks[track.recordingId] = [track]; + } + } + + await Platform.writeFileByName( + treeUri, parentId, 'musicus.json', jsonEncode(musicusFile.toJson())); + } +} diff --git a/lib/platform.dart b/lib/platform.dart index 0e25777..3284621 100644 --- a/lib/platform.dart +++ b/lib/platform.dart @@ -47,4 +47,51 @@ class Platform { .map((childJson) => Document.fromJson(childJson)) .toList(); } + + /// Read contents of file. + /// + /// [treeId] is the base URI from the SAF, [id] is the document ID of the + /// file. + static Future readFile(String treeUri, String id) async { + return await _platform.invokeMethod( + 'readFile', + { + 'treeUri': treeUri, + 'id': id, + }, + ); + } + + /// Read contents of file by name + /// + /// [treeId] is the base URI from the SAF, [parentId] is the document ID of + /// the parent directory. + static Future readFileByName( + String treeUri, String parentId, String fileName) async { + return await _platform.invokeMethod( + 'readFileByName', + { + 'treeUri': treeUri, + 'parentId': parentId, + 'fileName': fileName, + }, + ); + } + + /// Write to file by name + /// + /// [treeId] is the base URI from the SAF, [parentId] is the document ID of + /// the parent directory. + static Future writeFileByName( + String treeUri, String parentId, String fileName, String content) async { + await _platform.invokeMethod( + 'writeFileByName', + { + 'treeUri': treeUri, + 'parentId': parentId, + 'fileName': fileName, + 'content': content, + }, + ); + } } diff --git a/lib/selectors/files.dart b/lib/selectors/files.dart index 896d47e..79a673b 100644 --- a/lib/selectors/files.dart +++ b/lib/selectors/files.dart @@ -4,18 +4,18 @@ import '../backend.dart'; import '../platform.dart'; /// Result of the user's interaction with the files selector. -/// +/// /// This will be given back when popping the navigator. class FilesSelectorResult { /// Document ID of the parent directory of the selected files. - /// + /// /// This will be null, if they are in the toplevel directory. final String parentId; - /// Document IDs of the selected files. - final List trackIds; + /// Selected files. + final Set selection; - FilesSelectorResult(this.parentId, this.trackIds); + FilesSelectorResult(this.parentId, this.selection); } class FilesSelector extends StatefulWidget { @@ -27,7 +27,7 @@ class _FilesSelectorState extends State { BackendState backend; List history = []; List children = []; - Set selectedIds = {}; + Set selection = {}; @override void didChangeDependencies() { @@ -57,7 +57,7 @@ class _FilesSelectorState extends State { context, FilesSelectorResult( history.isNotEmpty ? history.last.id : null, - selectedIds.toList(), + selection, ), ); }, @@ -99,13 +99,13 @@ class _FilesSelectorState extends State { controlAffinity: ListTileControlAffinity.trailing, secondary: const Icon(Icons.insert_drive_file), title: Text(document.name), - value: selectedIds.contains(document.id), + value: selection.contains(document), onChanged: (selected) { setState(() { if (selected) { - selectedIds.add(document.id); + selection.add(document); } else { - selectedIds.remove(document.id); + selection.remove(document); } }); }, @@ -124,6 +124,10 @@ class _FilesSelectorState extends State { Future loadChildren() async { setState(() { children = []; + + // We reset the selection here, because the user should not be able to + // select files from multiple directories for now. + selection = {}; }); final newChildren = await Platform.getChildren(