fix(android): preserve remote SAF descriptors for audio and artwork

This commit is contained in:
zarzet committed 2026-10-03 02:14:06 +07:00
1 parent b767bd16d8
commit bbe2caa754
32 files changed
+2290 -22

No files matched your search

@@ -0,0 +1,121 @@
package com.zarz.spotiflac
import android.graphics.Bitmap
import android.media.MediaPlayer
import android.os.Handler
import android.os.HandlerThread
import android.os.ParcelFileDescriptor
import android.os.ProxyFileDescriptorCallback
import android.os.storage.StorageManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.spotiflac.backend.readFileMetadata
import com.spotiflac.backend.readLibraryMetadataFromDescriptor
import java.io.ByteArrayOutputStream
import java.io.DataOutputStream
import java.io.FileInputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import xyz.luan.audioplayers.source.UrlSource
/** AppFuse has the same single-open restriction as rclone's SAF provider. */
@RunWith(AndroidJUnit4::class)
class RemoteSafPlaybackTest {
private fun <T> withProxy(bytes: ByteArray, size: Long = bytes.size.toLong(),
block: (ParcelFileDescriptor, AtomicLong) -> T): T {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val thread = HandlerThread("remote-document-fixture").apply { start() }
val reads = AtomicLong()
val released = CountDownLatch(1)
val storage = context.getSystemService(StorageManager::class.java)
try {
return storage.openProxyFileDescriptor(ParcelFileDescriptor.MODE_READ_ONLY,
object : ProxyFileDescriptorCallback() {
override fun onGetSize() = size
override fun onRelease() { released.countDown() }
override fun onRead(offset: Long, length: Int, data: ByteArray): Int {
val count = minOf(length.toLong(), (size - offset).coerceAtLeast(0)).toInt()
data.fill(0, 0, count)
if (offset < bytes.size) {
val available = minOf(count, bytes.size - offset.toInt())
bytes.copyInto(data, 0, offset.toInt(), offset.toInt() + available)
}
reads.addAndGet(count.toLong())
return count
}
}, Handler(thread.looper)).use { block(it, reads) }
} finally { released.await(2, TimeUnit.SECONDS); thread.quitSafely() }
}
@Test fun proxyPlaybackUsesDescriptorWithoutReopeningOrCopying() {
val dataSize = 44100 * 2
val wav = ByteBuffer.allocate(44 + dataSize).order(ByteOrder.LITTLE_ENDIAN).apply {
put("RIFF".toByteArray()); putInt(36 + dataSize); put("WAVEfmt ".toByteArray())
putInt(16); putShort(1); putShort(1); putInt(44100); putInt(88200)
putShort(2); putShort(16); put("data".toByteArray()); putInt(dataSize)
}.array()
withProxy(wav) { fd, _ ->
val path = "/proc/self/fd/${fd.fd}"
// Reproduce the old path before exercising the patched plugin.
assertTrue(runCatching { FileInputStream(path).use { it.read() } }.isFailure)
val prepared = CountDownLatch(1)
val failed = AtomicLong()
lateinit var player: MediaPlayer
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.runOnMainSync {
player = MediaPlayer().apply {
setOnPreparedListener { prepared.countDown() }
setOnErrorListener { _, _, _ -> failed.incrementAndGet(); prepared.countDown(); true }
UrlSource(path, true).setForMediaPlayer(this)
prepareAsync()
}
}
try {
assertTrue("Proxy must prepare without a full file copy", prepared.await(10, TimeUnit.SECONDS))
assertEquals(0L, failed.get())
instrumentation.runOnMainSync { assertTrue(player.duration > 0) }
} finally { instrumentation.runOnMainSync { player.release() } }
}
}
@Test fun proxyMetadataAndCoverReadOnlyTagsAndKeepOriginalLeaseOpen() {
val image = ByteArrayOutputStream().apply {
val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888)
bitmap.compress(Bitmap.CompressFormat.PNG, 100, this)
bitmap.recycle()
}.toByteArray()
val picture = ByteArrayOutputStream().apply {
DataOutputStream(this).apply {
writeInt(3); writeInt(9); write("image/png".toByteArray()); writeInt(0)
writeInt(2); writeInt(2); writeInt(32); writeInt(0)
writeInt(image.size); write(image)
}
}.toByteArray()
val streamInfo = ByteBuffer.allocate(34).apply {
putShort(4096); putShort(4096); position(10)
putLong((44100L shl 44) or (1L shl 41) or (15L shl 36) or 44100L)
}.array()
val header = ByteArrayOutputStream().apply {
write("fLaC".toByteArray()); write(byteArrayOf(0, 0, 0, 34)); write(streamInfo)
write(0x86); write(picture.size shr 16); write(picture.size shr 8); write(picture.size)
write(picture)
write(0xff); write(0xf8) // First audio frame's FLAC sync marker.
}.toByteArray()
withProxy(header, 64L * 1024 * 1024) { fd, reads ->
val result = readLibraryMetadataFromDescriptor(fd.fd, "Remote.flac", "", true, null)
assertTrue(result.coverBytes.isNotEmpty())
assertEquals(44100, JSONObject(result.metadataJson).getInt("sampleRate"))
val complete = JSONObject(readFileMetadata("/proc/self/fd/${fd.fd}", "Remote.flac", null))
assertEquals(16, complete.getInt("bit_depth"))
assertTrue("Metadata must not transfer 64 MiB of audio", reads.get() < 1024 * 1024)
assertTrue(fd.fileDescriptor.valid())
}
}
}
@@ -211,7 +211,7 @@ internal interface CoreBackend {
fun editFileMetadata(path: String, metadataJson: String): String
fun reEnrichFile(requestJson: String): String
fun rewriteSplitArtistTags(path: String, artist: String, albumArtist: String): String
fun extractCoverToFile(audioPath: String, outputPath: String)
fun extractCoverToFile(audioPath: String, outputPath: String, hint: String = "")
fun writeM4aFreeformTags(path: String, metadataJson: String): String
fun ensureAc4Config(path: String, reference: String): String
fun writeAc4Metadata(path: String, metadataJson: String, coverPath: String): String
@@ -887,9 +887,9 @@ class MainActivity: FlutterFragmentActivity() {
/**
* Opens a short-lived descriptor lease for zero-copy SAF playback. The
* returned proc path has no URI scheme, so MediaPlayer opens it in this
* process and duplicates the descriptor before the Dart side closes the
* lease. A small hard cap protects against abandoned method calls.
* returned proc path identifies the lease. The Android audio plugin must
* duplicate this descriptor, never reopen it (AppFuse rejects reopening).
* A small hard cap protects against abandoned method calls.
*/
private fun openSafPlaybackLease(uriStr: String): Map<String, String>? {
if (!uriStr.startsWith("content://")) return null
@@ -900,6 +900,10 @@ class MainActivity: FlutterFragmentActivity() {
null
} ?: return null
if (!isSeekableSafDescriptor(descriptor)) {
descriptor.close()
return null
}
val token = UUID.randomUUID().toString()
synchronized(playbackLeaseLock) {
while (playbackLeases.size >= 4) {
@@ -1732,15 +1736,8 @@ class MainActivity: FlutterFragmentActivity() {
val response = withContext(Dispatchers.IO) {
try {
if (audioPath.startsWith("content://")) {
val uri = Uri.parse(audioPath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"success":false,"error":"Failed to copy SAF file to temp"}"""
try {
coreBackend.extractCoverToFile(tempPath, outputPath)
"""{"success":true}"""
} finally {
try { File(tempPath).delete() } catch (_: Exception) {}
}
extractCoverFromUri(Uri.parse(audioPath), outputPath)
"""{"success":true}"""
} else {
coreBackend.extractCoverToFile(audioPath, outputPath)
"""{"success":true}"""
@@ -252,7 +252,7 @@ internal fun MainActivity.buildLibraryCoverCacheKey(stablePath: String, lastModi
return if (lastModified > 0L) "$normalizedPath|$lastModified" else normalizedPath
}
private fun isSeekableSafDescriptor(descriptor: ParcelFileDescriptor): Boolean {
internal fun isSeekableSafDescriptor(descriptor: ParcelFileDescriptor): Boolean {
return try {
val position = Os.lseek(
descriptor.fileDescriptor,
@@ -340,6 +340,23 @@ internal fun MainActivity.readCompleteMetadataFromUri(
JSONObject(coreBackend.readFileMetadata(path, name)).takeUnless { it.has("error") }
}
/** Seekable remote documents need only tag reads, not a full audio download. */
internal fun MainActivity.extractCoverFromUri(uri: Uri, outputPath: String) {
val name = buildUriDisplayName(uri)
contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
if (isSeekableSafDescriptor(descriptor)) {
coreBackend.extractCoverToFile("/proc/self/fd/${descriptor.fd}", outputPath, name)
return
}
}
// Providers exposing a pipe cannot support random-access tag readers.
val tempPath = copyUriToTemp(uri, displayName = name)
?: error("Failed to read document artwork")
try {
coreBackend.extractCoverToFile(tempPath, outputPath)
} finally { File(tempPath).delete() }
}
internal fun MainActivity.writeUriFromPath(uri: Uri, srcPath: String): Boolean {
val srcFile = File(srcPath)
if (!srcFile.exists()) return false
@@ -386,7 +386,23 @@ internal object RustCoreBackend : CoreBackend {
override fun rewriteSplitArtistTags(path: String, artist: String, albumArtist: String): String =
withMediaFiles(listOf(path)) { it.rewriteSplitArtistTags(mediaPath(path), artist, albumArtist, null) }
override fun extractCoverToFile(audioPath: String, outputPath: String) {
override fun extractCoverToFile(audioPath: String, outputPath: String, hint: String) {
val descriptor = if (audioPath.startsWith("/proc/self/fd/")) {
audioPath.removePrefix("/proc/self/fd/").toIntOrNull()
} else null
if (descriptor != null) {
val result = com.spotiflac.backend.readLibraryMetadataFromDescriptor(
descriptor, hint, java.time.Instant.now().toString(), true, null,
)
check(result.coverBytes.isNotEmpty()) { "No embedded artwork" }
val output = File(outputPath)
val staged = File.createTempFile("cover_", ".tmp", output.parentFile)
try {
staged.writeBytes(result.coverBytes)
check(staged.renameTo(output)) { "Could not publish artwork" }
} finally { staged.delete() }
return
}
withMediaFiles(listOf(audioPath, outputPath)) {
it.extractCoverToFile(mediaPath(audioPath), mediaPath(outputPath), null)
}