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:
1 parent
9387b50b67
commit
0add6de716
27 files changed
+890
-3
No files matched your search
+3
-3
@@ -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") }
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user