mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 14:07:26 +02:00
fix(android): preserve remote SAF descriptors for audio and artwork
This commit is contained in:
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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user