fix(ci): il pattern gitignore 'scores/' escludeva anche i sorgenti

'scores/' senza slash iniziale ignorava ogni directory 'scores' a
qualsiasi livello: mancavano dal repo il package com.notemusicali.scores,
ScoresScreen e i 12 spartiti bundled. Ancorato a '/scores/' (solo la
libreria alla radice) e aggiunti i file mancanti.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-07-22 10:38:51 +02:00
1 parent 9387b50b67
commit 0add6de716
27 files changed
+890 -3

No files matched your search

+3 -3
View File
@@ -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
@@ -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()
}
@@ -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<ScoreEntry> {
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<java.io.File> { !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<ScoreEntry> {
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<ScoreEntry>()
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<ScoreEntry> { !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
}
}
}
@@ -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") }
}
}
@@ -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<ScoreEntry> = 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
}
}
}
@@ -0,0 +1,3 @@
package com.notemusicali.scores
expect fun extractMusicXmlFromBytes(bytes: ByteArray): String?
@@ -0,0 +1,7 @@
package com.notemusicali.scores
data class ScoreEntry(
val name: String,
val path: String,
val isDirectory: Boolean,
)
@@ -0,0 +1,8 @@
package com.notemusicali.scores
expect object ScoreProvider {
fun listScores(relativePath: String = ""): List<ScoreEntry>
fun readScoreBytes(path: String): ByteArray?
fun listFromUri(treeUri: String, documentId: String = ""): List<ScoreEntry>
fun readFromUri(treeUri: String, documentId: String): ByteArray?
}
@@ -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<String>()) }
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<String?>(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,
)
}
}
}
@@ -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)
}
}
@@ -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("<score-partwise>"), "expected score XML, got: $xml")
assertTrue(!xml!!.contains("<container>"), "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("<octave>4</octave>"), "expected score XML, got: $xml")
}
@Test
fun `passes plain xml through unchanged`() {
val plain = "<?xml version=\"1.0\"?><score-partwise/>"
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))
}
}
@@ -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()
}
@@ -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<ScoreEntry> {
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<java.io.File> { !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<ScoreEntry> {
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<java.io.File> { !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 }
}
}
@@ -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<z_stream>()
// Negative windowBits selects raw deflate (no zlib header), as used by ZIP
val init = inflateInit2_(stream.ptr, -15, zlibVersion()?.toKString(), sizeOf<z_stream>().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)
}
}
}
@@ -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<ScoreEntry> {
return emptyList()
}
actual fun readScoreBytes(path: String): ByteArray? {
return null
}
@OptIn(ExperimentalForeignApi::class)
actual fun listFromUri(treeUri: String, documentId: String): List<ScoreEntry> {
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<ScoreEntry>()
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<ScoreEntry> { !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<ObjCObjectVar<NSError?>>()
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()
}
}
}