diff --git a/.gitignore b/.gitignore index 18d29d0..9887ff7 100644 --- a/.gitignore +++ b/.gitignore @@ -22,9 +22,9 @@ iosApp/build/ video/node_modules/ video/out/ -# Score library (256MB of .mxl, not source code — the curated bundled set -# lives in shared/src/commonMain/composeResources/files/scores/) -scores/ +# Score library at the repo root (256MB of .mxl, not source code — the curated +# bundled set lives in shared/src/commonMain/composeResources/files/scores/) +/scores/ # Binary release artifacts *.apk diff --git a/shared/src/androidMain/kotlin/com/notemusicali/scores/MxlReader.android.kt b/shared/src/androidMain/kotlin/com/notemusicali/scores/MxlReader.android.kt new file mode 100644 index 0000000..cc82328 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/notemusicali/scores/MxlReader.android.kt @@ -0,0 +1,25 @@ +package com.notemusicali.scores + +import java.util.zip.ZipInputStream + +actual fun extractMusicXmlFromBytes(bytes: ByteArray): String? { + // Check for ZIP magic bytes (PK\x03\x04) = MXL file + if (bytes.size >= 4 && bytes[0] == 0x50.toByte() && bytes[1] == 0x4B.toByte()) { + return try { + ZipInputStream(bytes.inputStream()).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + if (entry.name.endsWith(".xml") && !entry.name.startsWith("META-INF")) { + return@use zis.bufferedReader().readText() + } + entry = zis.nextEntry + } + null + } + } catch (_: Exception) { + null + } + } + // Plain XML + return bytes.decodeToString() +} diff --git a/shared/src/androidMain/kotlin/com/notemusicali/scores/ScoreProvider.android.kt b/shared/src/androidMain/kotlin/com/notemusicali/scores/ScoreProvider.android.kt new file mode 100644 index 0000000..2eb8013 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/notemusicali/scores/ScoreProvider.android.kt @@ -0,0 +1,92 @@ +package com.notemusicali.scores + +import android.net.Uri +import android.provider.DocumentsContract + +actual object ScoreProvider { + actual fun listScores(relativePath: String): List { + val baseDir = ScoreProviderContext.scoresDir ?: return emptyList() + val dir = if (relativePath.isEmpty()) baseDir + else java.io.File(baseDir, relativePath) + if (!dir.exists() || !dir.isDirectory) return emptyList() + + return dir.listFiles() + ?.filter { it.isDirectory || it.extension.lowercase() in listOf("mxl", "xml", "musicxml") } + ?.sortedWith(compareBy { !it.isDirectory }.thenBy { it.name.lowercase() }) + ?.map { file -> + ScoreEntry( + name = if (file.isDirectory) file.name else file.nameWithoutExtension, + path = if (relativePath.isEmpty()) file.name + else "$relativePath/${file.name}", + isDirectory = file.isDirectory, + ) + } + ?: emptyList() + } + + actual fun readScoreBytes(path: String): ByteArray? { + val baseDir = ScoreProviderContext.scoresDir ?: return null + val file = java.io.File(baseDir, path) + if (!file.exists()) return null + return try { + file.readBytes() + } catch (_: Exception) { + null + } + } + + actual fun listFromUri(treeUri: String, documentId: String): List { + val context = ScoreProviderContext.appContext ?: return emptyList() + val treeUriParsed = Uri.parse(treeUri) + val parentDocId = if (documentId.isEmpty()) { + DocumentsContract.getTreeDocumentId(treeUriParsed) + } else { + documentId + } + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUriParsed, parentDocId) + val results = mutableListOf() + try { + context.contentResolver.query( + childrenUri, + arrayOf( + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + ), + null, null, null, + )?.use { cursor -> + val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE) + val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + while (cursor.moveToNext()) { + val name = cursor.getString(nameCol) ?: continue + val mime = cursor.getString(mimeCol) ?: "" + val docId = cursor.getString(idCol) ?: continue + val isDir = mime == DocumentsContract.Document.MIME_TYPE_DIR + val ext = name.substringAfterLast(".", "").lowercase() + if (isDir || ext in listOf("mxl", "xml", "musicxml")) { + results.add( + ScoreEntry( + name = if (isDir) name else name.substringBeforeLast("."), + path = docId, + isDirectory = isDir, + ) + ) + } + } + } + } catch (_: Exception) {} + return results.sortedWith(compareBy { !it.isDirectory }.thenBy { it.name.lowercase() }) + } + + actual fun readFromUri(treeUri: String, documentId: String): ByteArray? { + val context = ScoreProviderContext.appContext ?: return null + val treeUriParsed = Uri.parse(treeUri) + val docUri = DocumentsContract.buildDocumentUriUsingTree(treeUriParsed, documentId) + return try { + context.contentResolver.openInputStream(docUri)?.use { it.readBytes() } + } catch (_: Exception) { + null + } + } +} diff --git a/shared/src/androidMain/kotlin/com/notemusicali/scores/ScoreProviderContext.kt b/shared/src/androidMain/kotlin/com/notemusicali/scores/ScoreProviderContext.kt new file mode 100644 index 0000000..c7d34dc --- /dev/null +++ b/shared/src/androidMain/kotlin/com/notemusicali/scores/ScoreProviderContext.kt @@ -0,0 +1,14 @@ +package com.notemusicali.scores + +import android.content.Context +import java.io.File + +object ScoreProviderContext { + var scoresDir: File? = null + var appContext: Context? = null + + fun init(context: Context, externalFilesDir: File?) { + appContext = context.applicationContext + scoresDir = externalFilesDir?.let { File(it, "scores") } + } +} diff --git a/shared/src/commonMain/composeResources/files/scores/bella_ciao.mxl b/shared/src/commonMain/composeResources/files/scores/bella_ciao.mxl new file mode 100644 index 0000000..332356a Binary files /dev/null and b/shared/src/commonMain/composeResources/files/scores/bella_ciao.mxl differ diff --git a/shared/src/commonMain/composeResources/files/scores/canone_pachelbel.mxl b/shared/src/commonMain/composeResources/files/scores/canone_pachelbel.mxl new file mode 100644 index 0000000..2226911 Binary files /dev/null and b/shared/src/commonMain/composeResources/files/scores/canone_pachelbel.mxl differ diff --git a/shared/src/commonMain/composeResources/files/scores/carol_of_the_bells.mxl b/shared/src/commonMain/composeResources/files/scores/carol_of_the_bells.mxl new file mode 100644 index 0000000..853f1b2 Binary files /dev/null and b/shared/src/commonMain/composeResources/files/scores/carol_of_the_bells.mxl differ diff --git a/shared/src/commonMain/composeResources/files/scores/fur_elise.mxl b/shared/src/commonMain/composeResources/files/scores/fur_elise.mxl new file mode 100644 index 0000000..77c8c38 Binary files /dev/null and b/shared/src/commonMain/composeResources/files/scores/fur_elise.mxl differ diff --git a/shared/src/commonMain/composeResources/files/scores/greensleeves.mxl b/shared/src/commonMain/composeResources/files/scores/greensleeves.mxl new file mode 100644 index 0000000..d67c11f Binary files /dev/null and b/shared/src/commonMain/composeResources/files/scores/greensleeves.mxl differ diff --git a/shared/src/commonMain/composeResources/files/scores/gymnopedie_satie.mxl b/shared/src/commonMain/composeResources/files/scores/gymnopedie_satie.mxl new file mode 100644 index 0000000..ed2e969 Binary files /dev/null and b/shared/src/commonMain/composeResources/files/scores/gymnopedie_satie.mxl differ diff --git a/shared/src/commonMain/composeResources/files/scores/happy_birthday.mxl b/shared/src/commonMain/composeResources/files/scores/happy_birthday.mxl new file mode 100644 index 0000000..c86348d Binary files /dev/null and b/shared/src/commonMain/composeResources/files/scores/happy_birthday.mxl differ diff --git a/shared/src/commonMain/composeResources/files/scores/lago_dei_cigni.mxl b/shared/src/commonMain/composeResources/files/scores/lago_dei_cigni.mxl new file mode 100644 index 0000000..49b16d7 Binary files /dev/null and b/shared/src/commonMain/composeResources/files/scores/lago_dei_cigni.mxl differ diff --git a/shared/src/commonMain/composeResources/files/scores/minuetto_bach.mxl b/shared/src/commonMain/composeResources/files/scores/minuetto_bach.mxl new file mode 100644 index 0000000..dd7f1b3 Binary files /dev/null and b/shared/src/commonMain/composeResources/files/scores/minuetto_bach.mxl differ diff --git a/shared/src/commonMain/composeResources/files/scores/notturno_chopin.mxl b/shared/src/commonMain/composeResources/files/scores/notturno_chopin.mxl new file mode 100644 index 0000000..46940ea Binary files /dev/null and b/shared/src/commonMain/composeResources/files/scores/notturno_chopin.mxl differ diff --git a/shared/src/commonMain/composeResources/files/scores/ode_to_joy.mxl b/shared/src/commonMain/composeResources/files/scores/ode_to_joy.mxl new file mode 100644 index 0000000..166d9f5 Binary files /dev/null and b/shared/src/commonMain/composeResources/files/scores/ode_to_joy.mxl differ diff --git a/shared/src/commonMain/composeResources/files/scores/preludio_bach.mxl b/shared/src/commonMain/composeResources/files/scores/preludio_bach.mxl new file mode 100644 index 0000000..134e9d1 Binary files /dev/null and b/shared/src/commonMain/composeResources/files/scores/preludio_bach.mxl differ diff --git a/shared/src/commonMain/kotlin/com/notemusicali/scores/BundledScores.kt b/shared/src/commonMain/kotlin/com/notemusicali/scores/BundledScores.kt new file mode 100644 index 0000000..5e452c6 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/scores/BundledScores.kt @@ -0,0 +1,47 @@ +package com.notemusicali.scores + +import notemusicali.shared.generated.resources.Res +import org.jetbrains.compose.resources.ExperimentalResourceApi + +/** + * Curated set of scores shipped inside the app as compose resources, so every + * platform has content available even before the user picks a folder. + */ +object BundledScores { + + private const val PREFIX = "bundled:" + + // Resource file name (under files/scores/) to display name + private val manifest = listOf( + "happy_birthday.mxl" to "Happy Birthday", + "ode_to_joy.mxl" to "Inno alla Gioia — Beethoven", + "fur_elise.mxl" to "Per Elisa — Beethoven", + "minuetto_bach.mxl" to "Minuetto in Sol — Bach", + "preludio_bach.mxl" to "Preludio in Do — Bach", + "canone_pachelbel.mxl" to "Canone in Re — Pachelbel", + "gymnopedie_satie.mxl" to "Gymnopédie n.1 — Satie", + "notturno_chopin.mxl" to "Notturno op.9 n.2 — Chopin", + "lago_dei_cigni.mxl" to "Il Lago dei Cigni — Čajkovskij", + "greensleeves.mxl" to "Greensleeves", + "carol_of_the_bells.mxl" to "Carol of the Bells", + "bella_ciao.mxl" to "Bella Ciao", + ) + + val entries: List = manifest.map { (file, displayName) -> + ScoreEntry(name = displayName, path = PREFIX + file, isDirectory = false) + } + + fun isBundled(path: String): Boolean = path.startsWith(PREFIX) + + @OptIn(ExperimentalResourceApi::class) + suspend fun read(path: String): ByteArray? { + if (!isBundled(path)) return null + val file = path.removePrefix(PREFIX) + if (manifest.none { it.first == file }) return null + return try { + Res.readBytes("files/scores/$file") + } catch (_: Throwable) { + null + } + } +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/scores/MxlReader.kt b/shared/src/commonMain/kotlin/com/notemusicali/scores/MxlReader.kt new file mode 100644 index 0000000..8e2cbd9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/scores/MxlReader.kt @@ -0,0 +1,3 @@ +package com.notemusicali.scores + +expect fun extractMusicXmlFromBytes(bytes: ByteArray): String? diff --git a/shared/src/commonMain/kotlin/com/notemusicali/scores/ScoreEntry.kt b/shared/src/commonMain/kotlin/com/notemusicali/scores/ScoreEntry.kt new file mode 100644 index 0000000..286f9f8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/scores/ScoreEntry.kt @@ -0,0 +1,7 @@ +package com.notemusicali.scores + +data class ScoreEntry( + val name: String, + val path: String, + val isDirectory: Boolean, +) diff --git a/shared/src/commonMain/kotlin/com/notemusicali/scores/ScoreProvider.kt b/shared/src/commonMain/kotlin/com/notemusicali/scores/ScoreProvider.kt new file mode 100644 index 0000000..616c45a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/scores/ScoreProvider.kt @@ -0,0 +1,8 @@ +package com.notemusicali.scores + +expect object ScoreProvider { + fun listScores(relativePath: String = ""): List + fun readScoreBytes(path: String): ByteArray? + fun listFromUri(treeUri: String, documentId: String = ""): List + fun readFromUri(treeUri: String, documentId: String): ByteArray? +} diff --git a/shared/src/commonMain/kotlin/com/notemusicali/ui/scores/ScoresScreen.kt b/shared/src/commonMain/kotlin/com/notemusicali/ui/scores/ScoresScreen.kt new file mode 100644 index 0000000..4918973 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/notemusicali/ui/scores/ScoresScreen.kt @@ -0,0 +1,249 @@ +package com.notemusicali.ui.scores + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.MusicNote +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.notemusicali.music.MusicXmlParser +import com.notemusicali.music.NoteSequence +import com.notemusicali.scan.AppSettings +import com.notemusicali.scores.BundledScores +import com.notemusicali.scores.ScoreEntry +import com.notemusicali.scores.ScoreProvider +import com.notemusicali.scores.extractMusicXmlFromBytes +import com.notemusicali.ui.components.BackTopBar +import notemusicali.shared.generated.resources.Res +import notemusicali.shared.generated.resources.* +import org.jetbrains.compose.resources.stringResource +import com.notemusicali.ui.components.GradientCard +import com.notemusicali.ui.components.rememberFolderPickerLauncher +import com.notemusicali.ui.theme.CardGradients +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun ScoresScreen( + onBack: () -> Unit, + onScoreSelected: (NoteSequence) -> Unit, +) { + var externalUri by remember { + mutableStateOf(AppSettings.getString("scores_folder_uri")?.ifEmpty { null }) + } + var externalName by remember { + mutableStateOf(AppSettings.getString("scores_folder_name") ?: "") + } + var pathStack by remember { mutableStateOf(listOf()) } + val currentPath = pathStack.lastOrNull() ?: "" + + val entries = remember(externalUri, currentPath) { + if (externalUri != null) { + ScoreProvider.listFromUri(externalUri!!, currentPath) + } else { + // App source: curated bundled scores first, then any file the user + // dropped in the app's scores directory + val bundled = if (currentPath.isEmpty()) BundledScores.entries else emptyList() + bundled + ScoreProvider.listScores(currentPath) + } + } + + var loadingPath by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + val folderPicker = rememberFolderPickerLauncher { uri, name -> + externalUri = uri + externalName = name + pathStack = emptyList() + AppSettings.putString("scores_folder_uri", uri) + AppSettings.putString("scores_folder_name", name) + } + + val scoresTitle = stringResource(Res.string.scores_title) + val title = if (pathStack.isEmpty()) { + if (externalUri != null) externalName.ifEmpty { scoresTitle } else scoresTitle + } else { + val last = pathStack.last() + last.substringAfterLast("/").substringAfterLast(":").ifEmpty { last } + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + Column(modifier = Modifier.widthIn(max = 600.dp).fillMaxHeight()) { + BackTopBar( + title = title, + onBack = { + if (pathStack.isNotEmpty()) { + pathStack = pathStack.dropLast(1) + } else { + onBack() + } + }, + contentColor = MaterialTheme.colorScheme.primary, + ) + + // Source selector + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (externalUri != null) stringResource(Res.string.folder_named, externalName) else stringResource(Res.string.folder_app), + fontSize = 12.sp, + color = Color.White.copy(alpha = 0.4f), + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + TextButton(onClick = { folderPicker() }) { + Icon( + Icons.Outlined.Folder, + contentDescription = stringResource(Res.string.change_folder), + modifier = Modifier.size(16.dp), + tint = Color.White.copy(alpha = 0.6f), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(Res.string.change), fontSize = 12.sp, color = Color.White.copy(alpha = 0.6f)) + } + if (externalUri != null) { + TextButton(onClick = { + externalUri = null + externalName = "" + pathStack = emptyList() + AppSettings.putString("scores_folder_uri", "") + AppSettings.putString("scores_folder_name", "") + }) { + Text("Reset", fontSize = 12.sp, color = Color.White.copy(alpha = 0.6f)) + } + } + } + + if (entries.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.no_scores_found), + fontSize = 16.sp, + color = Color.White.copy(alpha = 0.5f), + textAlign = TextAlign.Center, + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(vertical = 16.dp), + ) { + items(entries) { entry -> + ScoreEntryCard( + entry = entry, + isLoading = loadingPath == entry.path, + onClick = { + if (entry.isDirectory) { + pathStack = pathStack + entry.path + } else if (loadingPath == null) { + loadingPath = entry.path + scope.launch { + val sequence = withContext(Dispatchers.Default) { + val bytes = when { + BundledScores.isBundled(entry.path) -> BundledScores.read(entry.path) + externalUri != null -> ScoreProvider.readFromUri(externalUri!!, entry.path) + else -> ScoreProvider.readScoreBytes(entry.path) + } + bytes ?: return@withContext null + val xml = extractMusicXmlFromBytes(bytes) + ?: return@withContext null + val seq = MusicXmlParser.parse(xml, entry.name) + if (seq.notes.isNotEmpty()) seq else null + } + loadingPath = null + if (sequence != null) { + onScoreSelected(sequence) + } + } + } + }, + ) + } + } + } + } + } +} + +@Composable +private fun ScoreEntryCard( + entry: ScoreEntry, + isLoading: Boolean, + onClick: () -> Unit, +) { + GradientCard( + gradient = if (entry.isDirectory) CardGradients.scores else CardGradients.scoreFile, + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + color = Color.White.copy(alpha = 0.8f), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = if (entry.isDirectory) Icons.Outlined.Folder else Icons.Outlined.MusicNote, + contentDescription = if (entry.isDirectory) stringResource(Res.string.folder) else stringResource(Res.string.score_file), + modifier = Modifier.size(28.dp), + tint = Color.White.copy(alpha = 0.7f), + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = entry.name, + fontSize = 16.sp, + fontWeight = if (entry.isDirectory) FontWeight.SemiBold else FontWeight.Normal, + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/shared/src/commonTest/kotlin/com/notemusicali/scores/BundledScoresTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/scores/BundledScoresTest.kt new file mode 100644 index 0000000..2e387d1 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/scores/BundledScoresTest.kt @@ -0,0 +1,43 @@ +package com.notemusicali.scores + +import com.notemusicali.music.MusicXmlParser +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class BundledScoresTest { + + @Test + fun `manifest lists bundled scores as non-directory entries with bundled prefix`() { + val entries = BundledScores.entries + assertTrue(entries.size >= 10, "expected a curated set of scores, got ${entries.size}") + entries.forEach { entry -> + assertTrue(BundledScores.isBundled(entry.path), "path ${entry.path} must carry the bundled prefix") + assertTrue(!entry.isDirectory, "bundled scores are plain files") + assertTrue(entry.name.isNotBlank() && !entry.name.contains('_'), "display name should be human readable, got ${entry.name}") + } + } + + @Test + fun `non-bundled paths are not recognized as bundled`() { + assertTrue(!BundledScores.isBundled("/storage/scores/foo.mxl")) + assertTrue(!BundledScores.isBundled("")) + } + + @Test + fun `bundled score can be read and parsed into notes`() = runTest { + val entry = BundledScores.entries.first() + val bytes = BundledScores.read(entry.path) + assertNotNull(bytes, "bundled score ${entry.path} must be readable") + val xml = extractMusicXmlFromBytes(bytes) + assertNotNull(xml, "bundled score must contain MusicXML") + val sequence = MusicXmlParser.parse(xml, entry.name) + assertTrue(sequence.notes.isNotEmpty(), "parsed sequence must contain notes") + } + + @Test + fun `reading an unknown bundled path returns null`() = runTest { + assertTrue(BundledScores.read("bundled:does_not_exist.mxl") == null) + } +} diff --git a/shared/src/commonTest/kotlin/com/notemusicali/scores/MxlReaderTest.kt b/shared/src/commonTest/kotlin/com/notemusicali/scores/MxlReaderTest.kt new file mode 100644 index 0000000..e2b6cfd --- /dev/null +++ b/shared/src/commonTest/kotlin/com/notemusicali/scores/MxlReaderTest.kt @@ -0,0 +1,47 @@ +package com.notemusicali.scores + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalEncodingApi::class) +class MxlReaderTest { + + // Both archives contain META-INF/container.xml plus score.xml (a minimal + // score-partwise document with one C4 quarter note). + private val storedMxl = Base64.decode( + "UEsDBBQAAAAAAAAAIVxZbzWxZAAAAGQAAAAWAAAATUVUQS1JTkYvY29udGFpbmVyLnhtbDw/eG1sIHZlcnNpb249IjEuMCI/Pjxjb250YWluZXI+PHJvb3RmaWxlcz48cm9vdGZpbGUgZnVsbC1wYXRoPSJzY29yZS54bWwiLz48L3Jvb3RmaWxlcz48L2NvbnRhaW5lcj5QSwMEFAAAAAAAAAAhXK2hgKfKAAAAygAAAAkAAABzY29yZS54bWw8P3htbCB2ZXJzaW9uPSIxLjAiIGVuY29kaW5nPSJVVEYtOCI/PjxzY29yZS1wYXJ0d2lzZT48cGFydCBpZD0iUDEiPjxtZWFzdXJlIG51bWJlcj0iMSI+PG5vdGU+PHBpdGNoPjxzdGVwPkM8L3N0ZXA+PG9jdGF2ZT40PC9vY3RhdmU+PC9waXRjaD48dHlwZT5xdWFydGVyPC90eXBlPjwvbm90ZT48L21lYXN1cmU+PC9wYXJ0Pjwvc2NvcmUtcGFydHdpc2U+UEsBAhQDFAAAAAAAAAAhXFlvNbFkAAAAZAAAABYAAAAAAAAAAAAAAIABAAAAAE1FVEEtSU5GL2NvbnRhaW5lci54bWxQSwECFAMUAAAAAAAAACFcraGAp8oAAADKAAAACQAAAAAAAAAAAAAAgAGYAAAAc2NvcmUueG1sUEsFBgAAAAACAAIAewAAAIkBAAAAAA==", + ) + + private val deflatedMxl = Base64.decode( + "UEsDBBQAAAAIAAAAIVxZbzWxTAAAAGQAAAAWAAAATUVUQS1JTkYvY29udGFpbmVyLnhtbLOxr8jNUShLLSrOzM+zVTLUM1Cyt7NJzs8rSczMSy2ysynKzy9Jy8xJLUYwFdJKc3J0CxJLMmyVipPzi1L1gGYo6dvZ6CMp1keYAQBQSwMEFAAAAAgAAAAhXK2hgKeQAAAAygAAAAkAAABzY29yZS54bWxdjjsOAjEMRK8SpV/CShQUjrdAoqaAA4SsBZHIh3wWuD3Jkopqxvb42TC97YMtFJPxTvJxs+WMnPazcTfJL+fjsOcTQtI+0hBUzC+TCKE5ZmbJTyNHsKRSicRcsVeKlVJ7zueWM1nf63qmgAcQq4LXWS2EOxDdgei5/AmEz1LhFEGsFYgfSfQjLVznVf5++gJQSwECFAMUAAAACAAAACFcWW81sUwAAABkAAAAFgAAAAAAAAAAAAAAgAEAAAAATUVUQS1JTkYvY29udGFpbmVyLnhtbFBLAQIUAxQAAAAIAAAAIVytoYCnkAAAAMoAAAAJAAAAAAAAAAAAAACAAYAAAABzY29yZS54bWxQSwUGAAAAAAIAAgB7AAAANwEAAAAA", + ) + + @Test + fun `extracts score xml from stored mxl archive skipping META-INF`() { + val xml = extractMusicXmlFromBytes(storedMxl) + assertTrue(xml != null && xml.contains(""), "expected score XML, got: $xml") + assertTrue(!xml!!.contains(""), "META-INF/container.xml must be skipped") + } + + @Test + fun `extracts score xml from deflate-compressed mxl archive`() { + val xml = extractMusicXmlFromBytes(deflatedMxl) + assertTrue(xml != null && xml.contains("4"), "expected score XML, got: $xml") + } + + @Test + fun `passes plain xml through unchanged`() { + val plain = "" + assertEquals(plain, extractMusicXmlFromBytes(plain.encodeToByteArray())) + } + + @Test + fun `returns null for corrupt zip data`() { + val corrupt = byteArrayOf(0x50, 0x4B, 0x03, 0x04, 1, 2, 3, 4, 5) + assertNull(extractMusicXmlFromBytes(corrupt)) + } +} diff --git a/shared/src/desktopMain/kotlin/com/notemusicali/scores/MxlReader.desktop.kt b/shared/src/desktopMain/kotlin/com/notemusicali/scores/MxlReader.desktop.kt new file mode 100644 index 0000000..29d3b6e --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/notemusicali/scores/MxlReader.desktop.kt @@ -0,0 +1,23 @@ +package com.notemusicali.scores + +import java.util.zip.ZipInputStream + +actual fun extractMusicXmlFromBytes(bytes: ByteArray): String? { + if (bytes.size >= 4 && bytes[0] == 0x50.toByte() && bytes[1] == 0x4B.toByte()) { + return try { + ZipInputStream(bytes.inputStream()).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + if (entry.name.endsWith(".xml") && !entry.name.startsWith("META-INF")) { + return@use zis.bufferedReader().readText() + } + entry = zis.nextEntry + } + null + } + } catch (_: Exception) { + null + } + } + return bytes.decodeToString() +} diff --git a/shared/src/desktopMain/kotlin/com/notemusicali/scores/ScoreProvider.desktop.kt b/shared/src/desktopMain/kotlin/com/notemusicali/scores/ScoreProvider.desktop.kt new file mode 100644 index 0000000..ef29902 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/notemusicali/scores/ScoreProvider.desktop.kt @@ -0,0 +1,64 @@ +package com.notemusicali.scores + +actual object ScoreProvider { + private val scoresDir: java.io.File? by lazy { + val userHome = System.getProperty("user.home") + val dir = java.io.File(userHome, "NoteMusicali/scores") + if (dir.exists()) dir else null + } + + actual fun listScores(relativePath: String): List { + val baseDir = scoresDir ?: return emptyList() + val dir = if (relativePath.isEmpty()) baseDir + else java.io.File(baseDir, relativePath) + if (!dir.exists() || !dir.isDirectory) return emptyList() + + return dir.listFiles() + ?.filter { it.isDirectory || it.extension.lowercase() in listOf("mxl", "xml", "musicxml") } + ?.sortedWith(compareBy { !it.isDirectory }.thenBy { it.name.lowercase() }) + ?.map { file -> + ScoreEntry( + name = if (file.isDirectory) file.name else file.nameWithoutExtension, + path = if (relativePath.isEmpty()) file.name + else "$relativePath/${file.name}", + isDirectory = file.isDirectory, + ) + } + ?: emptyList() + } + + actual fun readScoreBytes(path: String): ByteArray? { + val baseDir = scoresDir ?: return null + val file = java.io.File(baseDir, path) + if (!file.exists()) return null + return try { + file.readBytes() + } catch (_: Exception) { + null + } + } + + actual fun listFromUri(treeUri: String, documentId: String): List { + val baseDir = java.io.File(treeUri) + val dir = if (documentId.isEmpty()) baseDir else java.io.File(baseDir, documentId) + if (!dir.exists() || !dir.isDirectory) return emptyList() + + return dir.listFiles() + ?.filter { it.isDirectory || it.extension.lowercase() in listOf("mxl", "xml", "musicxml") } + ?.sortedWith(compareBy { !it.isDirectory }.thenBy { it.name.lowercase() }) + ?.map { file -> + ScoreEntry( + name = if (file.isDirectory) file.name else file.nameWithoutExtension, + path = if (documentId.isEmpty()) file.name else "$documentId/${file.name}", + isDirectory = file.isDirectory, + ) + } + ?: emptyList() + } + + actual fun readFromUri(treeUri: String, documentId: String): ByteArray? { + val file = java.io.File(treeUri, documentId) + if (!file.exists()) return null + return try { file.readBytes() } catch (_: Exception) { null } + } +} diff --git a/shared/src/iosMain/kotlin/com/notemusicali/scores/MxlReader.ios.kt b/shared/src/iosMain/kotlin/com/notemusicali/scores/MxlReader.ios.kt new file mode 100644 index 0000000..5fdb79a --- /dev/null +++ b/shared/src/iosMain/kotlin/com/notemusicali/scores/MxlReader.ios.kt @@ -0,0 +1,124 @@ +package com.notemusicali.scores + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.sizeOf +import kotlinx.cinterop.toKString +import kotlinx.cinterop.usePinned +import platform.zlib.Z_FINISH +import platform.zlib.Z_OK +import platform.zlib.Z_STREAM_END +import platform.zlib.inflate +import platform.zlib.inflateEnd +import platform.zlib.inflateInit2_ +import platform.zlib.z_stream +import platform.zlib.zlibVersion + +actual fun extractMusicXmlFromBytes(bytes: ByteArray): String? { + if (bytes.size >= 4 && bytes[0] == 0x50.toByte() && bytes[1] == 0x4B.toByte()) { + return try { + extractFromZip(bytes) + } catch (_: Throwable) { + null + } + } + return bytes.decodeToString() +} + +private fun readU16(b: ByteArray, off: Int): Int = + (b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8) + +private fun readU32(b: ByteArray, off: Int): Long = + (b[off].toLong() and 0xFF) or + ((b[off + 1].toLong() and 0xFF) shl 8) or + ((b[off + 2].toLong() and 0xFF) shl 16) or + ((b[off + 3].toLong() and 0xFF) shl 24) + +private const val EOCD_SIGNATURE = 0x06054b50L +private const val CENTRAL_DIR_SIGNATURE = 0x02014b50L +private const val EOCD_MIN_SIZE = 22 +private const val LOCAL_HEADER_SIZE = 30 +private const val METHOD_STORED = 0 +private const val METHOD_DEFLATED = 8 + +/** + * Minimal ZIP reader driven by the central directory: returns the content of the + * first .xml entry outside META-INF, mirroring the JVM implementations. + */ +private fun extractFromZip(bytes: ByteArray): String? { + // The End Of Central Directory record sits at the end of the file, possibly + // followed by a comment of up to 65535 bytes. + var eocd = -1 + var i = bytes.size - EOCD_MIN_SIZE + val stop = maxOf(0, bytes.size - EOCD_MIN_SIZE - 0xFFFF) + while (i >= stop) { + if (readU32(bytes, i) == EOCD_SIGNATURE) { + eocd = i + break + } + i-- + } + if (eocd < 0) return null + + val entryCount = readU16(bytes, eocd + 10) + var offset = readU32(bytes, eocd + 16).toInt() + + repeat(entryCount) { + if (offset + 46 > bytes.size || readU32(bytes, offset) != CENTRAL_DIR_SIGNATURE) return null + val method = readU16(bytes, offset + 10) + val compressedSize = readU32(bytes, offset + 20).toInt() + val uncompressedSize = readU32(bytes, offset + 24).toInt() + val nameLen = readU16(bytes, offset + 28) + val extraLen = readU16(bytes, offset + 30) + val commentLen = readU16(bytes, offset + 32) + val localHeaderOffset = readU32(bytes, offset + 42).toInt() + val name = bytes.decodeToString(offset + 46, offset + 46 + nameLen) + offset += 46 + nameLen + extraLen + commentLen + + if (name.endsWith(".xml") && !name.startsWith("META-INF")) { + // Name/extra field lengths in the local header can differ from the + // central directory ones, so the data offset must be re-derived here. + val lhNameLen = readU16(bytes, localHeaderOffset + 26) + val lhExtraLen = readU16(bytes, localHeaderOffset + 28) + val dataStart = localHeaderOffset + LOCAL_HEADER_SIZE + lhNameLen + lhExtraLen + if (dataStart + compressedSize > bytes.size) return null + val data = bytes.copyOfRange(dataStart, dataStart + compressedSize) + return when (method) { + METHOD_STORED -> data.decodeToString() + METHOD_DEFLATED -> inflateRaw(data, uncompressedSize)?.decodeToString() + else -> null + } + } + } + return null +} + +@OptIn(ExperimentalForeignApi::class) +private fun inflateRaw(compressed: ByteArray, uncompressedSize: Int): ByteArray? { + if (uncompressedSize <= 0 || compressed.isEmpty()) return null + val output = ByteArray(uncompressedSize) + return memScoped { + val stream = alloc() + // Negative windowBits selects raw deflate (no zlib header), as used by ZIP + val init = inflateInit2_(stream.ptr, -15, zlibVersion()?.toKString(), sizeOf().toInt()) + if (init != Z_OK) return@memScoped null + try { + compressed.usePinned { inPin -> + output.usePinned { outPin -> + stream.next_in = inPin.addressOf(0).reinterpret() + stream.avail_in = compressed.size.toUInt() + stream.next_out = outPin.addressOf(0).reinterpret() + stream.avail_out = uncompressedSize.toUInt() + if (inflate(stream.ptr, Z_FINISH) != Z_STREAM_END) return@memScoped null + } + } + output + } finally { + inflateEnd(stream.ptr) + } + } +} diff --git a/shared/src/iosMain/kotlin/com/notemusicali/scores/ScoreProvider.ios.kt b/shared/src/iosMain/kotlin/com/notemusicali/scores/ScoreProvider.ios.kt new file mode 100644 index 0000000..d1b0bc9 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/notemusicali/scores/ScoreProvider.ios.kt @@ -0,0 +1,141 @@ +package com.notemusicali.scores + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCObjectVar +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.Foundation.NSError +import platform.Foundation.NSFileManager +import platform.Foundation.NSFileType +import platform.Foundation.NSFileTypeDirectory +import platform.Foundation.NSURL +import platform.Foundation.dataWithBytes +import platform.Foundation.dataWithContentsOfFile +import platform.posix.memcpy +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +actual object ScoreProvider { + + private val allowedExtensions = listOf("mxl", "xml", "musicxml") + + actual fun listScores(relativePath: String): List { + return emptyList() + } + + actual fun readScoreBytes(path: String): ByteArray? { + return null + } + + @OptIn(ExperimentalForeignApi::class) + actual fun listFromUri(treeUri: String, documentId: String): List { + val rootUrl = resolveBookmark(treeUri) ?: return emptyList() + val accessing = rootUrl.startAccessingSecurityScopedResource() + try { + val rootPath = rootUrl.path ?: return emptyList() + val dirPath = if (documentId.isEmpty()) rootPath else "$rootPath/$documentId" + + val fm = NSFileManager.defaultManager + val contents = fm.contentsOfDirectoryAtPath(dirPath, error = null) ?: return emptyList() + + val results = mutableListOf() + for (item in contents) { + val name = item as? String ?: continue + if (name.startsWith(".")) continue + + val fullPath = "$dirPath/$name" + val isDir = isDirectoryAtPath(fullPath) + val ext = name.substringAfterLast(".", "").lowercase() + + if (isDir || ext in allowedExtensions) { + val relativePath = if (documentId.isEmpty()) name else "$documentId/$name" + results.add( + ScoreEntry( + name = if (isDir) name else name.substringBeforeLast("."), + path = relativePath, + isDirectory = isDir, + ) + ) + } + } + return results.sortedWith( + compareBy { !it.isDirectory }.thenBy { it.name.lowercase() } + ) + } finally { + if (accessing) rootUrl.stopAccessingSecurityScopedResource() + } + } + + @OptIn(ExperimentalForeignApi::class) + actual fun readFromUri(treeUri: String, documentId: String): ByteArray? { + val rootUrl = resolveBookmark(treeUri) ?: return null + val accessing = rootUrl.startAccessingSecurityScopedResource() + try { + val rootPath = rootUrl.path ?: return null + val filePath = "$rootPath/$documentId" + val data = NSData.dataWithContentsOfFile(filePath) ?: return null + return data.toByteArray() + } finally { + if (accessing) rootUrl.stopAccessingSecurityScopedResource() + } + } + + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class, ExperimentalEncodingApi::class) + private fun resolveBookmark(treeUri: String): NSURL? { + if (!treeUri.startsWith("bookmark:")) return null + val base64 = treeUri.removePrefix("bookmark:") + val bytes = try { + Base64.decode(base64) + } catch (_: Exception) { + return null + } + val bookmarkData = bytes.toNSData() + return try { + memScoped { + val errorPtr = alloc>() + NSURL( + byResolvingBookmarkData = bookmarkData, + options = 0u, + relativeToURL = null, + bookmarkDataIsStale = null, + error = errorPtr.ptr, + ) + } + } catch (_: Exception) { + null + } + } + + @OptIn(ExperimentalForeignApi::class) + private fun isDirectoryAtPath(path: String): Boolean { + val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, error = null) + ?: return false + return attrs[NSFileType] == NSFileTypeDirectory + } + + @OptIn(ExperimentalForeignApi::class) + private fun NSData.toByteArray(): ByteArray { + val size = length.toInt() + if (size == 0) return ByteArray(0) + val src = this.bytes ?: return ByteArray(0) + return ByteArray(size).apply { + usePinned { pinned -> + memcpy(pinned.addressOf(0), src, this@toByteArray.length) + } + } + } + + @OptIn(ExperimentalForeignApi::class) + private fun ByteArray.toNSData(): NSData { + if (isEmpty()) return NSData() + return usePinned { pinned -> + NSData.dataWithBytes(pinned.addressOf(0), size.toULong()) + ?: NSData() + } + } +}