fix(storage): retain document access for library metadata and covers

This commit is contained in:
zarzet committed 2026-09-30 21:42:53 +07:00
1 parent cda9868a1f
commit c5789df87d
5 files changed
+298 -14

No files matched your search

@@ -36,6 +36,76 @@ class NativeDownloadContainerTest {
backendRoot.deleteRecursively()
}
@Test
fun selectedLibraryFilesCanBeEditedReenrichedAndExportArtworkOutsideDownloadRoot() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val directory = File(context.filesDir, "selected-library-${System.nanoTime()}").apply { mkdirs() }
try {
val input = File(directory, "selected.flac")
val fixture = NativeDownloadFinalizer.runFFmpegArguments(arrayOf(
"-v", "error", "-f", "lavfi", "-i", "sine=frequency=997:sample_rate=48000",
"-t", "0.5", "-c:a", "flac", input.path,
))
assertTrue(fixture.second, fixture.first)
val artwork = File(directory, "selected.png")
val bitmap = android.graphics.Bitmap.createBitmap(4, 4, android.graphics.Bitmap.Config.ARGB_8888)
bitmap.eraseColor(android.graphics.Color.BLUE)
artwork.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
bitmap.recycle()
val edit = JSONObject(backend.editFileMetadata(input.path, JSONObject()
.put("title", "Edited title").put("cover_path", artwork.path)
.put("replaygain_track_gain", "-7.00 dB").put("replaygain_track_peak", "0.750000").toString()))
assertTrue(edit.toString(), edit.getBoolean("success"))
val request = JSONObject().put("file_path", input.path).put("search_online", false)
.put("track_name", "Enriched title").put("artist_name", "Example artist")
.put("update_fields", org.json.JSONArray(listOf("track_name")))
val enriched = JSONObject(backend.reEnrichFile(request.toString()))
assertTrue(enriched.toString(), enriched.getBoolean("success"))
val metadata = JSONObject(backend.readFileMetadata(input.path, input.name))
assertEquals("Enriched title", metadata.getString("title"))
assertEquals("-7.00 dB", metadata.getString("replaygain_track_gain"))
val output = File(directory, "export.png")
backend.extractCoverToFile(input.path, output.path)
assertTrue(output.readBytes().contentEquals(artwork.readBytes()))
val library = JSONObject(backend.readAudioMetadata(input.path, input.name, ""))
assertEquals("Enriched title", library.getString("trackName"))
} finally { directory.deleteRecursively() }
}
@Test
fun libraryMetadataReadsAnOpenDescriptorWithoutGrantingItsFilesystemPath() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val artwork = File(backendRoot, "selected-cover.png")
val bitmap = android.graphics.Bitmap.createBitmap(4, 4, android.graphics.Bitmap.Config.ARGB_8888)
bitmap.eraseColor(android.graphics.Color.GREEN)
artwork.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
bitmap.recycle()
val input = File(backendRoot, "selected-track.flac")
val fixture = NativeDownloadFinalizer.runFFmpegArguments(arrayOf(
"-v", "error", "-f", "lavfi", "-i", "sine=frequency=997:sample_rate=48000",
"-i", artwork.path, "-t", "0.5", "-map", "0:a", "-map", "1:v",
"-c:a", "flac", "-c:v", "copy", "-disposition:v", "attached_pic",
"-metadata", "title=Document track", input.path,
))
assertTrue(fixture.second, fixture.first)
val covers = File(context.cacheDir, "descriptor-covers-${System.nanoTime()}").apply { mkdirs() }
try {
backend.setLibraryCoverCacheDirectory(covers.path)
android.os.ParcelFileDescriptor.open(input, android.os.ParcelFileDescriptor.MODE_READ_ONLY).use { descriptor ->
val result = JSONObject(backend.readAudioMetadata(
"/proc/self/fd/${descriptor.fd}", input.name, "content://example/tree/music/selected-track.flac",
))
assertEquals("Document track", result.getString("trackName"))
assertEquals(48000, result.getInt("sampleRate"))
assertFalse(result.optBoolean("metadataFromFilename"))
val cachedCover = File(result.getString("coverPath"))
assertEquals(covers.canonicalPath, cachedCover.parentFile!!.canonicalPath)
assertTrue(cachedCover.readBytes().contentEquals(artwork.readBytes()))
assertTrue(descriptor.statSize > 0)
}
} finally { covers.deleteRecursively() }
}
@Test
fun nativeFormatsPreserveTagsLyricsAndReplayGain() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
@@ -58,6 +58,7 @@ internal object RustCoreBackend : CoreBackend {
private var runtimeState: Pair<String, String>? = null
private val directoryScopes = mutableMapOf<String, AutoCloseable>()
private var libraryCoverScope: AutoCloseable? = null
@Volatile private var libraryCoverDirectory: File? = null
@Synchronized
fun initialize(context: Context): RustCoreBackend {
@@ -209,6 +210,7 @@ internal object RustCoreBackend : CoreBackend {
directoryScopes.clear()
libraryCoverScope?.close()
libraryCoverScope = null
libraryCoverDirectory = null
identity = null
runtimeState = null
}
@@ -221,8 +223,53 @@ internal object RustCoreBackend : CoreBackend {
override fun checkHiResAuthenticity(path: String, optionsJson: String): String =
com.spotiflac.backend.checkHiresAuthenticity(path, optionsJson, null)
override fun readAudioMetadata(path: String, hint: String, cacheKey: String): String =
owner().readAudioMetadata(File(path).canonicalPath, hint, cacheKey, null)
override fun readAudioMetadata(path: String, hint: String, cacheKey: String): String {
val descriptor = path.removePrefix("/proc/self/fd/").toIntOrNull()
if (path.startsWith("/proc/self/fd/") && descriptor != null) {
val directory = libraryCoverDirectory
// Never key artwork by the descriptor number: Android reuses it.
val key = cacheKey.trim().takeIf { it.isNotEmpty() }
val hash = key?.codePoints()?.reduce(5381) { hash, codePoint -> hash * 33 + codePoint }
?.toUInt()?.toString(16)
val cached = if (directory != null && hash != null) {
listOf(File(directory, "cover_$hash.jpg"), File(directory, "cover_$hash.png"))
.firstOrNull { it.isFile }
} else null
val result = com.spotiflac.backend.readLibraryMetadataFromDescriptor(
descriptor, hint, java.time.Instant.now().toString(),
directory != null && hash != null && cached == null, null,
)
val metadata = JSONObject(result.metadataJson)
var cover = cached
if (directory != null && hash != null && result.coverBytes.isNotEmpty()) {
try {
val extension = if (result.coverMime.contains("png")) "png" else "jpg"
val output = File(directory, "cover_$hash.$extension")
val staged = File.createTempFile("cover_", ".tmp", directory)
try {
staged.writeBytes(result.coverBytes)
check(staged.renameTo(output)) { "Could not publish library artwork" }
cover = output
} finally { staged.delete() }
} catch (error: Exception) {
android.util.Log.w("SpotiFLAC", "Could not cache document artwork", error)
}
}
cover?.let { metadata.put("coverPath", it.path) }
return metadata.toString()
}
return withMediaFiles(listOf(path)) {
it.readAudioMetadata(File(path).canonicalPath, hint, cacheKey, null)
}
}
// Native callers have already selected/resolved these files through app or
// SAF access. Retain only their parent directories for this operation.
private fun <T> withMediaFiles(paths: List<String>, block: (ExtensionManager) -> T): T =
withLibraryDirectories(paths.filter { it.isNotBlank() }.map { path ->
require(File(path).isAbsolute) { "Media paths must be absolute" }
File(path).absoluteFile.parent!!
}.distinct(), block)
private fun <T> withLibraryDirectories(paths: List<String>, block: (ExtensionManager) -> T): T {
val (current, scope) = synchronized(this) {
@@ -247,6 +294,7 @@ internal object RustCoreBackend : CoreBackend {
catch (error: Exception) { next?.close(); throw error }
libraryCoverScope?.close()
libraryCoverScope = next
libraryCoverDirectory = directory
}
override fun scanLibraryFolder(folder: String): String = withLibraryDirectories(listOf(folder)) {
@@ -314,8 +362,12 @@ internal object RustCoreBackend : CoreBackend {
}
}
override fun editFileMetadata(path: String, metadataJson: String): String =
owner().editFileMetadata(File(path).canonicalPath, metadataJson, null)
override fun editFileMetadata(path: String, metadataJson: String): String {
val cover = if (metadataJson.trim() == "null") "" else JSONObject(metadataJson).optString("cover_path", "")
return withMediaFiles(listOf(path, cover)) {
it.editFileMetadata(File(path).canonicalPath, metadataJson, null)
}
}
private fun mediaPath(path: String): String = if (path.isEmpty()) "" else File(path).canonicalPath
@@ -325,18 +377,22 @@ internal object RustCoreBackend : CoreBackend {
if (!request.optBoolean("preview_only", false) && path?.startsWith("/") == true) {
request.put("file_path", mediaPath(path))
}
return owner().reenrichFile(request.toString(), null)
return if (!request.optBoolean("preview_only", false) && !path.isNullOrBlank()) {
withMediaFiles(listOf(path)) { it.reenrichFile(request.toString(), null) }
} else owner().reenrichFile(request.toString(), null)
}
override fun rewriteSplitArtistTags(path: String, artist: String, albumArtist: String): String =
owner().rewriteSplitArtistTags(mediaPath(path), artist, albumArtist, null)
withMediaFiles(listOf(path)) { it.rewriteSplitArtistTags(mediaPath(path), artist, albumArtist, null) }
override fun extractCoverToFile(audioPath: String, outputPath: String) {
owner().extractCoverToFile(mediaPath(audioPath), mediaPath(outputPath), null)
withMediaFiles(listOf(audioPath, outputPath)) {
it.extractCoverToFile(mediaPath(audioPath), mediaPath(outputPath), null)
}
}
override fun writeM4aFreeformTags(path: String, metadataJson: String): String =
owner().writeM4aFreeformTags(mediaPath(path), metadataJson, null)
withMediaFiles(listOf(path)) { it.writeM4aFreeformTags(mediaPath(path), metadataJson, null) }
override fun ensureAc4Config(path: String, reference: String): String =
owner().ensureAc4Config(mediaPath(path), mediaPath(reference), null)