perf(android): batch SAF lookups during download publication

This commit is contained in:
zarzet committed 2026-09-17 03:30:30 +07:00
1 parent 3eb44dc892
commit c868aac2cc
11 files changed
+668 -48

No files matched your search

@@ -1234,9 +1234,9 @@ class MainActivity: FlutterFragmentActivity() {
if (treeUriStr.isBlank()) return@withContext null
if (fileName.isBlank()) return@withContext null
val dir = SafDownloadHandler.ensureDocumentDir(this@MainActivity, Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
val existing = dir.findFile(fileName)
val existing = findSafChild(this@MainActivity, dir, fileName)
val createdNew = existing == null
val doc = SafDownloadHandler.createOrReuseDocumentFile(dir, mimeType, fileName)
val doc = SafDownloadHandler.createOrReuseDocumentFile(this@MainActivity, dir, mimeType, fileName)
?: return@withContext null
if (!writeUriFromPath(doc.uri, srcPath)) {
if (createdNew) {
@@ -1268,6 +1268,7 @@ class MainActivity: FlutterFragmentActivity() {
.put("uri", writeResult.uri)
.put("file_name", writeResult.fileName)
.put("already_exists", writeResult.alreadyExists)
.put("publish_timings_ms", JSONObject(writeResult.publishTimingsMs))
.toString()
}
}
@@ -1294,6 +1295,7 @@ class MainActivity: FlutterFragmentActivity() {
JSONObject()
.put("uri", writeResult.uri)
.put("file_name", writeResult.fileName)
.put("publish_timings_ms", JSONObject(writeResult.publishTimingsMs))
.toString()
}
}
@@ -1326,6 +1328,7 @@ class MainActivity: FlutterFragmentActivity() {
JSONObject()
.put("uri", writeResult.uri)
.put("file_name", writeResult.fileName)
.put("publish_timings_ms", JSONObject(writeResult.publishTimingsMs))
.toString()
}
}
@@ -367,6 +367,7 @@ internal fun MainActivity.writeSafSidecarLrc(audioUri: Uri, lrcContent: String):
val lrcName = "$baseName.lrc"
val target = SafDownloadHandler.createOrReuseDocumentFile(
this,
parent,
"application/octet-stream",
lrcName
@@ -128,11 +128,13 @@ internal fun NativeDownloadFinalizer.publishDeferredSafOutput(
srcPath = outputFile.absolutePath,
)?.let { result ->
alreadyExists = result.alreadyExists
SafDownloadHandler.UniqueWriteResult(result.uri, result.fileName)
SafDownloadHandler.UniqueWriteResult(result.uri, result.fileName, result.publishTimingsMs)
}
} ?: throw IllegalStateException("failed to publish deferred SAF output")
val newUri = published.uri
val publishedName = published.fileName
input.result.put("publish_timings_ms", JSONObject(published.publishTimingsMs))
Log.d(TAG, "SAF publish timings (ms): ${published.publishTimingsMs}")
outputFile.delete()
state.filePath = newUri
@@ -0,0 +1,70 @@
package com.zarz.spotiflac
import android.content.Context
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
internal fun findSafChild(
context: Context,
parent: DocumentFile,
name: String,
): DocumentFile? = findSafChildren(context, parent, setOf(name))[name]
/** Reads names with IDs in one query instead of querying each child's name. */
internal fun findSafChildren(
context: Context,
parent: DocumentFile,
names: Set<String>,
): Map<String, DocumentFile> {
if (names.isEmpty()) return emptyMap()
val children = try {
querySafChildren(context, parent, names)
} catch (_: Exception) {
null
}
// A successful miss is final. Retry only when the provider cannot support
// the projected query, keeping compatibility with unusual providers.
if (children != null) return children
return buildMap {
for (name in names) {
parent.findFile(name)?.let { put(name, it) }
}
}
}
private fun querySafChildren(
context: Context,
parent: DocumentFile,
names: Set<String>,
): Map<String, DocumentFile>? {
val parentUri = parent.uri
if (!DocumentsContract.isTreeUri(parentUri)) return null
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
parentUri,
DocumentsContract.getDocumentId(parentUri),
)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
)
val cursor = context.contentResolver.query(childrenUri, projection, null, null, null)
?: return null
return cursor.use {
val idColumn = it.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameColumn = it.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
if (idColumn < 0 || nameColumn < 0) return null
val found = linkedMapOf<String, DocumentFile>()
while (it.moveToNext()) {
val name = it.getString(nameColumn) ?: continue
if (name !in names || name in found) continue
val id = it.getString(idColumn)?.takeIf(String::isNotEmpty) ?: return null
val childUri = DocumentsContract.buildDocumentUriUsingTree(parentUri, id)
// AndroidX 1.1.0 preserves the child ID in a tree document URI.
// fromSingleUri would disable directory operations and rename.
val child = DocumentFile.fromTreeUri(context, childUri) ?: return null
found[name] = child
if (found.size == names.size) break
}
found
}
}
@@ -25,13 +25,33 @@ object SafDownloadHandler {
// the exists check and reports already_exists.
private val safNameLocks = KeyedLockPool<String>()
data class UniqueWriteResult(val uri: String, val fileName: String)
data class UniqueWriteResult(
val uri: String,
val fileName: String,
val publishTimingsMs: Map<String, Long> = emptyMap(),
)
data class ExistingAwareWriteResult(
val uri: String,
val fileName: String,
val alreadyExists: Boolean,
val publishTimingsMs: Map<String, Long> = emptyMap(),
)
private class PublishTrace {
private val started = System.nanoTime()
private var previous = started
private val stages = linkedMapOf<String, Long>()
fun mark(stage: String) {
val now = System.nanoTime()
stages[stage] = (now - previous) / 1_000_000
previous = now
}
fun finish(): Map<String, Long> = stages.toMap() +
("total" to (System.nanoTime() - started) / 1_000_000)
}
private fun <T> withSafNameLock(
treeUriStr: String,
relativeDir: String,
@@ -96,9 +116,9 @@ object SafDownloadHandler {
val existingDir = findDocumentDir(context, treeUri, relativeDir)
if (existingDir != null && req.optString("album_folder_template", "").isBlank()) {
val existing = existingDir.findFile(fileName)
val existing = findSafChild(context, existingDir, fileName)
if (existing != null && existing.isFile && existing.length() > 0) {
deleteStaleStagedFiles(existingDir, fileName, outputExt)
deleteStaleStagedFiles(context, existingDir, fileName, outputExt)
val obj = JSONObject()
obj.put("success", true)
obj.put("message", "File already exists")
@@ -110,7 +130,7 @@ object SafDownloadHandler {
}
if (deferSafPublish) {
existingDir?.let { deleteStaleStagedFiles(it, fileName, outputExt) }
existingDir?.let { deleteStaleStagedFiles(context, it, fileName, outputExt) }
val workingExt = outputExt.ifBlank { ".tmp" }
val workingFile = backend.createTemporaryMediaFile(context, "native_saf_work_", workingExt)
return try {
@@ -158,8 +178,8 @@ object SafDownloadHandler {
// creating the staged document: reusing it would let a shorter new
// write leave the old tail bytes in place (fd truncation is
// best-effort on some providers).
deleteStaleStagedFiles(targetDir, fileName, outputExt)
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
deleteStaleStagedFiles(context, targetDir, fileName, outputExt)
var document = createOrReuseDocumentFile(context, targetDir, stagedMimeType, stagedFileName)
?: return errorJson("Failed to create SAF file")
var pfd: android.os.ParcelFileDescriptor? = null
@@ -224,6 +244,7 @@ object SafDownloadHandler {
}
val actualMimeType = mimeTypeForExt(actualExt)
val replacement = createOrReuseDocumentFile(
context,
targetDir,
if (useStagedOutput) STAGED_SAF_MIME_TYPE else actualMimeType,
actualStagedFileName
@@ -260,7 +281,7 @@ object SafDownloadHandler {
} else if (useStagedOutput) {
// Legacy caller (foreground Dart queue): publish here by
// renaming the staged file to its final name.
val published = replaceFinalDocument(targetDir, document, finalFileName)
val published = replaceFinalDocument(context, targetDir, document, finalFileName)
if (published == null) {
document.delete()
return errorJson("Failed to publish SAF download")
@@ -294,29 +315,32 @@ object SafDownloadHandler {
* document, or null when the swap failed (the caller owns [document]).
*/
private fun replaceFinalDocument(
context: Context,
targetDir: DocumentFile,
document: DocumentFile,
finalName: String
): DocumentFile? {
val existingFinal = targetDir.findFile(finalName)
val existingFinal = findSafChild(context, targetDir, finalName)
var aside: DocumentFile? = null
if (existingFinal != null && existingFinal.uri != document.uri) {
val asideName = buildReplacedSafFileName(finalName)
try {
targetDir.findFile(asideName)?.delete()
findSafChild(context, targetDir, asideName)?.delete()
} catch (_: Exception) {
}
if (!existingFinal.renameTo(asideName)) {
return null
}
aside = targetDir.findFile(asideName) ?: existingFinal
// TreeDocumentFile.renameTo updates this object's URI, including
// providers whose document IDs change with the display name.
aside = existingFinal
}
if (!document.renameTo(finalName)) {
aside?.renameTo(finalName)
return null
}
aside?.delete()
return targetDir.findFile(finalName) ?: document
return document
}
private fun buildReplacedSafFileName(fileName: String): String {
@@ -361,7 +385,14 @@ object SafDownloadHandler {
): String? {
val finalName = sanitizeFilename(fileName)
return withSafNameLock(treeUriStr, sanitizeRelativeDir(relativeDir), finalName) {
writeFileToSafLocked(context, treeUriStr, relativeDir, finalName, srcPath)
try {
val targetDir = ensureDocumentDir(context, Uri.parse(treeUriStr), relativeDir)
?: return@withSafNameLock null
writeFileToSafLocked(context, targetDir, finalName, srcPath)
} catch (e: Exception) {
android.util.Log.w("SpotiFLAC", "Failed to write file to SAF: ${e.message}")
null
}
}
}
@@ -376,22 +407,27 @@ object SafDownloadHandler {
): UniqueWriteResult? {
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
val preferredName = sanitizeFilenamePreservingSuffix(fileName, preservedSuffix)
val trace = PublishTrace()
return withSafNameLock(treeUriStr, safeRelativeDir, preferredName) {
trace.mark("lock_wait")
val treeUri = Uri.parse(treeUriStr)
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir) ?: return@withSafNameLock null
trace.mark("directory")
val availableName = findAvailableFileName(
context,
targetDir,
preferredName,
preservedSuffix,
)
trace.mark("existing_check")
val uri = writeFileToSafLocked(
context,
treeUriStr,
safeRelativeDir,
targetDir,
availableName,
srcPath,
trace,
) ?: return@withSafNameLock null
UniqueWriteResult(uri = uri, fileName = availableName)
UniqueWriteResult(uri = uri, fileName = availableName, publishTimingsMs = trace.finish())
}
}
@@ -411,23 +447,27 @@ object SafDownloadHandler {
variantFileName,
preservedSuffix,
)
val trace = PublishTrace()
return withSafNameLock(treeUriStr, safeRelativeDir, cleanName) {
trace.mark("lock_wait")
val treeUri = Uri.parse(treeUriStr)
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir)
?: return@withSafNameLock null
val selectedName = if (targetDir.findFile(cleanName) == null) {
trace.mark("directory")
val selectedName = if (findSafChild(context, targetDir, cleanName) == null) {
cleanName
} else {
findAvailableFileName(targetDir, preferredVariant, preservedSuffix)
findAvailableFileName(context, targetDir, preferredVariant, preservedSuffix)
}
trace.mark("existing_check")
val uri = writeFileToSafLocked(
context,
treeUriStr,
safeRelativeDir,
targetDir,
selectedName,
srcPath,
trace,
) ?: return@withSafNameLock null
UniqueWriteResult(uri = uri, fileName = selectedName)
UniqueWriteResult(uri = uri, fileName = selectedName, publishTimingsMs = trace.finish())
}
}
@@ -441,29 +481,35 @@ object SafDownloadHandler {
): ExistingAwareWriteResult? {
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
val finalName = sanitizeFilename(fileName)
val trace = PublishTrace()
return withSafNameLock(treeUriStr, safeRelativeDir, finalName) {
trace.mark("lock_wait")
val treeUri = Uri.parse(treeUriStr)
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir)
?: return@withSafNameLock null
val existing = targetDir.findFile(finalName)
trace.mark("directory")
val existing = findSafChild(context, targetDir, finalName)
trace.mark("existing_check")
if (existing != null && existing.isFile && existing.length() > 0L) {
return@withSafNameLock ExistingAwareWriteResult(
uri = existing.uri.toString(),
fileName = existing.name ?: finalName,
alreadyExists = true,
publishTimingsMs = trace.finish(),
)
}
val uri = writeFileToSafLocked(
context,
treeUriStr,
safeRelativeDir,
targetDir,
finalName,
srcPath,
trace,
) ?: return@withSafNameLock null
ExistingAwareWriteResult(
uri = uri,
fileName = finalName,
alreadyExists = false,
publishTimingsMs = trace.finish(),
)
}
}
@@ -490,18 +536,19 @@ object SafDownloadHandler {
}
private fun findAvailableFileName(
context: Context,
parent: DocumentFile,
preferredName: String,
preservedSuffix: String,
): String {
if (parent.findFile(preferredName) == null) return preferredName
if (findSafChild(context, parent, preferredName) == null) return preferredName
for (counter in 2..9999) {
val candidate = appendFilenameCounter(
preferredName,
counter.toLong(),
preservedSuffix,
)
if (parent.findFile(candidate) == null) return candidate
if (findSafChild(context, parent, candidate) == null) return candidate
}
return appendFilenameCounter(
preferredName,
@@ -540,20 +587,20 @@ object SafDownloadHandler {
private fun writeFileToSafLocked(
context: Context,
treeUriStr: String,
relativeDir: String,
targetDir: DocumentFile,
finalName: String,
srcPath: String
srcPath: String,
trace: PublishTrace = PublishTrace(),
): String? {
var stagedDocument: DocumentFile? = null
return try {
val treeUri = Uri.parse(treeUriStr)
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
val stagedName = buildStagedSafFileName(finalName)
deleteStaleStagedFiles(targetDir, finalName, ext)
val document = createOrReuseDocumentFile(targetDir, STAGED_SAF_MIME_TYPE, stagedName)
deleteStaleStagedFiles(context, targetDir, finalName, ext)
trace.mark("cleanup")
val document = createOrReuseDocumentFile(context, targetDir, STAGED_SAF_MIME_TYPE, stagedName)
?: return null
trace.mark("create")
stagedDocument = document
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
if (outputStream == null) {
@@ -561,14 +608,19 @@ object SafDownloadHandler {
stagedDocument = null
return null
}
trace.mark("open")
outputStream.use { output ->
File(srcPath).inputStream().use { input ->
input.copyTo(output)
input.copyTo(output, bufferSize = 64 * 1024)
}
trace.mark("copy")
syncOutputStream(output)
trace.mark("sync")
}
trace.mark("close")
val published = replaceFinalDocument(targetDir, document, finalName)
val published = replaceFinalDocument(context, targetDir, document, finalName)
trace.mark("replace")
if (published == null) {
document.delete()
return null
@@ -644,15 +696,20 @@ object SafDownloadHandler {
return "$safeName.partial"
}
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
private fun deleteStaleStagedFiles(context: Context, parent: DocumentFile, fileName: String, outputExt: String) {
val stagedNames = linkedSetOf(
buildStagedSafFileName(fileName),
buildLegacyStagedSafFileName(fileName, outputExt),
buildReplacedSafFileName(fileName)
)
for (stagedName in stagedNames) {
val staleDocuments = try {
findSafChildren(context, parent, stagedNames)
} catch (_: Exception) {
return
}
for (document in staleDocuments.values) {
try {
parent.findFile(stagedName)?.delete()
document.delete()
} catch (_: Exception) {
}
}
@@ -740,7 +797,7 @@ object SafDownloadHandler {
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
for (part in parts) {
val existing = current.findFile(part)
val existing = findSafChild(context, current, part)
current = if (existing != null && existing.isDirectory) {
existing
} else {
@@ -748,7 +805,7 @@ object SafDownloadHandler {
val createdName = created.name ?: part
if (createdName != part) {
created.delete()
current.findFile(part) ?: return null
findSafChild(context, current, part) ?: return null
} else {
created
}
@@ -769,7 +826,7 @@ object SafDownloadHandler {
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
for (part in parts) {
val existing = current.findFile(part)
val existing = findSafChild(context, current, part)
if (existing == null || !existing.isDirectory) return null
current = existing
}
@@ -777,6 +834,7 @@ object SafDownloadHandler {
}
internal fun createOrReuseDocumentFile(
context: Context,
parent: DocumentFile,
mimeType: String,
fileName: String
@@ -785,7 +843,7 @@ object SafDownloadHandler {
if (safeFileName.isBlank()) return null
synchronized(safDirLock) {
val existing = parent.findFile(safeFileName)
val existing = findSafChild(context, parent, safeFileName)
if (existing != null && existing.isFile) {
return existing
}
@@ -796,7 +854,7 @@ object SafDownloadHandler {
return created
}
val winner = parent.findFile(safeFileName)
val winner = findSafChild(context, parent, safeFileName)
if (winner != null && winner.isFile) {
if (winner.uri != created.uri) {
try {