From ac90e7f5178453c252e7956fd417c1ae88ce53ff Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 26 Jul 2026 23:30:26 +0700 Subject: [PATCH] refactor(android): split SAF scan and URI IO helpers out of MainActivity --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 1396 +---------------- .../com/zarz/spotiflac/MainActivitySafIo.kt | 467 ++++++ .../com/zarz/spotiflac/MainActivitySafScan.kt | 1004 ++++++++++++ 3 files changed, 1477 insertions(+), 1390 deletions(-) create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index aa366741..74fbcd59 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -71,8 +71,8 @@ class MainActivity: FlutterFragmentActivity() { private var backendChannel: MethodChannel? = null private val pendingSessionGrantEvents = mutableListOf>() private var pendingSafTreeResult: MethodChannel.Result? = null - private val safScanLock = Any() - private var safScanProgress = SafScanProgress() + internal val safScanLock = Any() + internal var safScanProgress = SafScanProgress() private var downloadProgressStreamJob: Job? = null private var downloadProgressEventSink: EventChannel.EventSink? = null private var lastDownloadProgressPayload: String? = null @@ -81,10 +81,10 @@ class MainActivity: FlutterFragmentActivity() { private var libraryScanProgressEventSink: EventChannel.EventSink? = null private var lastLibraryScanProgressPayload: String? = null private var flutterBackCallback: OnBackPressedCallback? = null - @Volatile private var safScanCancel = false - @Volatile private var safScanActive = false + @Volatile internal var safScanCancel = false + @Volatile internal var safScanActive = false /** Tri-state: null = untested, true = works, false = fails (Samsung SELinux). */ - @Volatile private var procSelfFdReadable: Boolean? = null + @Volatile internal var procSelfFdReadable: Boolean? = null private val safTreeLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { activityResult -> @@ -143,12 +143,6 @@ class MainActivity: FlutterFragmentActivity() { } } - private fun buildStableLibraryId(filePath: String): String { - val digest = MessageDigest.getInstance("SHA-1") - val bytes = digest.digest(filePath.toByteArray(Charsets.UTF_8)) - val hex = bytes.joinToString("") { "%02x".format(it) } - return "lib_$hex" - } data class SafScanProgress( var totalFiles: Int = 0, @@ -342,40 +336,6 @@ class MainActivity: FlutterFragmentActivity() { .build() } - private fun resetSafScanProgress() { - synchronized(safScanLock) { - safScanProgress = SafScanProgress() - } - // Allow re-probing /proc/self/fd readability on every new scan session. - procSelfFdReadable = null - } - - private fun updateSafScanProgress(block: (SafScanProgress) -> Unit) { - synchronized(safScanLock) { - block(safScanProgress) - } - } - - private fun safProgressToJson(): String { - val snapshot = synchronized(safScanLock) { safScanProgress.copy() } - val obj = JSONObject() - obj.put("total_files", snapshot.totalFiles) - obj.put("scanned_files", snapshot.scannedFiles) - obj.put("current_file", snapshot.currentFile) - obj.put("error_count", snapshot.errorCount) - obj.put("progress_pct", snapshot.progressPct) - obj.put("is_complete", snapshot.isComplete) - return obj.toString() - } - - private fun readLibraryScanProgressJsonForStream(): String { - return if (safScanActive) { - safProgressToJson() - } else { - Gobackend.getLibraryScanProgressJSON() - } - } - private fun parseJsonValue(value: Any?): Any? { return when (value) { null, JSONObject.NULL -> null @@ -440,7 +400,7 @@ class MainActivity: FlutterFragmentActivity() { * payload inline when it is small (same contract as [bridgeJsonResult]), * otherwise the spill-file map. */ - private inner class SpillJsonWriter { + internal inner class SpillJsonWriter { val file = File(cacheDir, "bridge_json_${System.nanoTime()}.json") private val writer = file.bufferedWriter(Charsets.UTF_8) @@ -550,1350 +510,6 @@ class MainActivity: FlutterFragmentActivity() { lastLibraryScanProgressPayload = null } - private fun loadExistingFilesFromSnapshot(snapshotPath: String): MutableMap { - val result = mutableMapOf() - if (snapshotPath.isBlank()) { - return result - } - - val snapshotFile = File(snapshotPath) - if (!snapshotFile.exists()) { - return result - } - - snapshotFile.forEachLine { line -> - if (line.isBlank()) return@forEachLine - val separatorIndex = line.indexOf('\t') - if (separatorIndex <= 0 || separatorIndex >= line.length - 1) { - return@forEachLine - } - val modTime = line.substring(0, separatorIndex).toLongOrNull() ?: 0L - val filePath = line.substring(separatorIndex + 1) - if (filePath.isNotEmpty()) { - result[filePath] = modTime - } - } - return result - } - - private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String { - val obj = JSONObject() - if (treeUriStr.isBlank() || fileName.isBlank()) { - obj.put("uri", "") - obj.put("relative_dir", "") - return obj.toString() - } - val safeRelativeDir = SafDownloadHandler.sanitizeRelativeDir(relativeDir) - val safeFileName = SafDownloadHandler.sanitizeFilename(fileName) - if (safeFileName.isBlank()) { - obj.put("uri", "") - obj.put("relative_dir", "") - return obj.toString() - } - - val treeUri = Uri.parse(treeUriStr) - val targetDir = SafDownloadHandler.findDocumentDir(this, treeUri, safeRelativeDir) - if (targetDir != null) { - val direct = targetDir.findFile(safeFileName) - if (direct != null && direct.isFile) { - obj.put("uri", direct.uri.toString()) - obj.put("relative_dir", safeRelativeDir) - return obj.toString() - } - } - - val root = DocumentFile.fromTreeUri(this, treeUri) ?: run { - obj.put("uri", "") - obj.put("relative_dir", "") - return obj.toString() - } - - val queue: ArrayDeque> = ArrayDeque() - queue.add(root to "") - var visited = 0 - val maxVisited = 20000 - - while (queue.isNotEmpty()) { - if (visited > maxVisited) break - val (dir, path) = queue.removeFirst() - for (child in dir.listFiles()) { - visited++ - if (child.isDirectory) { - val childName = child.name ?: continue - val childPath = if (path.isBlank()) childName else "$path/$childName" - queue.add(child to childPath) - } else if (child.isFile) { - if (child.name == safeFileName) { - obj.put("uri", child.uri.toString()) - obj.put("relative_dir", path) - return obj.toString() - } - } - } - } - - obj.put("uri", "") - obj.put("relative_dir", "") - return obj.toString() - } - - private fun errorJson(message: String): String { - val obj = JSONObject() - obj.put("success", false) - obj.put("error", message) - obj.put("message", message) - return obj.toString() - } - - /** - * Detect whether a content URI belongs to the MediaStore provider. - * Samsung One UI may return MediaStore URIs from SAF tree traversal, - * which require READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission - * instead of SAF tree permission. - */ - private fun isMediaStoreUri(uri: Uri): Boolean { - val authority = uri.authority ?: return false - return authority == "media" || - authority.startsWith("media.") || - authority.contains("media") - } - - /** - * Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE. - */ - private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String { - try { - contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val name = cursor.getString(0)?.lowercase(Locale.ROOT) ?: "" - val ext = extFromFileName(name) - if (ext.isNotBlank()) return ext - } - } - } catch (_: Exception) {} - - try { - val mime = contentResolver.getType(uri) - val ext = extFromMimeType(mime) - if (ext.isNotBlank()) return ext - } catch (_: Exception) {} - - return fallbackExt ?: "" - } - - private fun extFromFileName(name: String): String { - return when { - name.endsWith(".m4a") -> ".m4a" - name.endsWith(".mp4") -> ".mp4" - name.endsWith(".aac") -> ".aac" - name.endsWith(".mp3") -> ".mp3" - name.endsWith(".opus") -> ".opus" - name.endsWith(".flac") -> ".flac" - name.endsWith(".ogg") -> ".ogg" - else -> "" - } - } - - private fun extFromMimeType(mime: String?): String { - return when (mime) { - "audio/mp4" -> ".m4a" - "audio/aac" -> ".aac" - "audio/eac3" -> ".m4a" - "audio/ac3" -> ".m4a" - "audio/ac4" -> ".m4a" - "audio/mpeg" -> ".mp3" - "audio/ogg" -> ".opus" - "audio/flac" -> ".flac" - "audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave" -> ".wav" - "audio/aiff", "audio/x-aiff" -> ".aiff" - else -> "" - } - } - - private fun copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? { - var tempFile: File? = null - var success = false - - try { - val mime = try { contentResolver.getType(uri) } catch (_: Exception) { null } - val nameHint = ( - try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null } - ?: uri.lastPathSegment - ?: "" - ).lowercase(Locale.ROOT) - val extFromName = extFromFileName(nameHint) - val extFromMime = extFromMimeType(mime) - val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "") - val suffix: String? = if (ext.isNotBlank()) ext else null - tempFile = File.createTempFile("saf_", suffix, cacheDir) - - contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(tempFile).use { output -> - input.copyTo(output) - } - } ?: return null - - success = true - return tempFile.absolutePath - } catch (e: SecurityException) { - // SAF permission denied - try MediaStore fallback for Samsung One UI - // which may return MediaStore URIs from SAF tree traversal - if (isMediaStoreUri(uri)) { - android.util.Log.d( - "SpotiFLAC", - "SAF denied for MediaStore URI, trying MediaStore fallback: $uri", - ) - val result = copyMediaStoreUriToTemp(uri, fallbackExt) - if (result != null) { - success = true - return result - } - } - android.util.Log.w( - "SpotiFLAC", - "SAF read denied for $uri: ${e.message}", - ) - return null - } catch (e: Exception) { - android.util.Log.w( - "SpotiFLAC", - "Failed copying SAF uri $uri to temp: ${e.message}", - ) - return null - } finally { - if (!success) { - try { - tempFile?.delete() - } catch (_: Exception) {} - } - } - } - - /** - * Fallback for Samsung One UI: read a MediaStore content URI using - * READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission instead of SAF. - * This handles the case where SAF tree traversal returns MediaStore URIs - * that the SAF document provider cannot access. - */ - private fun copyMediaStoreUriToTemp(uri: Uri, fallbackExt: String?): String? { - var tempFile: File? = null - try { - val ext = resolveMediaStoreExt(uri, fallbackExt) - val suffix: String? = if (ext.isNotBlank()) ext else null - tempFile = File.createTempFile("ms_", suffix, cacheDir) - - contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(tempFile).use { output -> - input.copyTo(output) - } - } ?: run { - tempFile.delete() - return null - } - - android.util.Log.d( - "SpotiFLAC", - "MediaStore fallback succeeded for $uri", - ) - return tempFile.absolutePath - } catch (e: Exception) { - android.util.Log.w( - "SpotiFLAC", - "MediaStore fallback also failed for $uri: ${e.message}", - ) - try { tempFile?.delete() } catch (_: Exception) {} - return null - } - } - - private fun buildUriDisplayName( - uri: Uri, - displayNameHint: String? = null, - fallbackExt: String? = null, - ): String { - val explicitName = displayNameHint?.trim().orEmpty() - if (explicitName.isNotEmpty()) return explicitName - - val docName = try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null } - val uriName = uri.lastPathSegment - val resolvedName = (docName ?: uriName ?: "").trim() - if (resolvedName.isNotEmpty()) return resolvedName - - val ext = when { - fallbackExt.isNullOrBlank().not() -> fallbackExt - isMediaStoreUri(uri) -> resolveMediaStoreExt(uri, fallbackExt) - else -> "" - } - return if (ext.isNullOrBlank()) "audio" else "audio$ext" - } - - private fun buildLibraryCoverCacheKey(stablePath: String, lastModified: Long): String { - val normalizedPath = stablePath.trim() - if (normalizedPath.isEmpty()) return "" - return if (lastModified > 0L) "$normalizedPath|$lastModified" else normalizedPath - } - - private fun readAudioMetadataFromUri( - uri: Uri, - displayNameHint: String? = null, - fallbackExt: String? = null, - coverCacheKey: String = "", - ): JSONObject? { - val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt) - - // Skip /proc/self/fd/ attempt when known to fail (e.g. Samsung SELinux). - if (procSelfFdReadable != false) { - try { - contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> - val directPath = "/proc/self/fd/${pfd.fd}" - val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON( - directPath, - displayName, - coverCacheKey, - ) - if (metadataJson.isNotBlank()) { - val obj = JSONObject(metadataJson) - val filenameFallback = obj.optBoolean("metadataFromFilename", false) - if (!obj.has("error") && !filenameFallback) { - procSelfFdReadable = true - return obj - } - // Go could not read real metadata from the fd path – - // remember so we skip the attempt for remaining files. - if (procSelfFdReadable == null) { - procSelfFdReadable = false - android.util.Log.d( - "SpotiFLAC", - "Direct /proc/self/fd read not usable on this device, " + - "using temp-file fallback for remaining files", - ) - } - } - } - } catch (e: Exception) { - if (procSelfFdReadable == null) { - procSelfFdReadable = false - android.util.Log.d( - "SpotiFLAC", - "Direct /proc/self/fd read not usable on this device, " + - "using temp-file fallback for remaining files", - ) - } - } - } - - val tempPath = try { - copyUriToTemp(uri, fallbackExt) - } catch (e: Exception) { - android.util.Log.w( - "SpotiFLAC", - "SAF metadata fallback copy failed for $uri: ${e.message}", - ) - null - } ?: return null - - try { - val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON( - tempPath, - displayName, - coverCacheKey, - ) - if (metadataJson.isBlank()) return null - val obj = JSONObject(metadataJson) - return if (obj.has("error")) null else obj - } catch (e: Exception) { - android.util.Log.w( - "SpotiFLAC", - "SAF metadata temp read failed for $uri: ${e.message}", - ) - return null - } finally { - try { - File(tempPath).delete() - } catch (_: Exception) {} - } - } - - private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean { - val srcFile = File(srcPath) - if (!srcFile.exists()) return false - contentResolver.openOutputStream(uri, "wt")?.use { output -> - FileInputStream(srcFile).use { input -> - input.copyTo(output) - } - } ?: return false - return true - } - - /** - * Get the parent DocumentFile directory for a SAF document URI. - * The child URI must be a tree-based document URI (e.g. from SAF tree scan). - * Returns a DocumentFile that supports findFile() for sibling lookup. - */ - private fun safParentDir(childUri: Uri): DocumentFile? { - try { - val docId = android.provider.DocumentsContract.getDocumentId(childUri) - if (docId.isNullOrEmpty()) return null - val lastSlash = docId.lastIndexOf('/') - if (lastSlash <= 0) return null - - val parentDocId = docId.substring(0, lastSlash) - val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri) - if (treeDocId.isNullOrEmpty()) return null - - val parentUri = android.provider.DocumentsContract.buildDocumentUriUsingTree( - childUri, parentDocId - ) - return DocumentFile.fromTreeUri(this, parentUri) - ?: DocumentFile.fromSingleUri(this, parentUri) - } catch (e: Exception) { - android.util.Log.w("SpotiFLAC", "Failed to get SAF parent dir: ${e.message}") - return null - } - } - - /** - * Write a ".lrc" sidecar next to a SAF audio document. The sidecar reuses - * the audio file's base name (e.g. "Song.flac" -> "Song.lrc") and is created - * in the same parent directory. Used by re-enrich when the user's lyrics - * mode requests an external/both sidecar. Best-effort: failures are logged - * and swallowed so they never abort the metadata enrichment itself. - */ - private fun writeSafSidecarLrc(audioUri: Uri, lrcContent: String): Boolean { - if (lrcContent.isBlank()) return false - try { - val parent = safParentDir(audioUri) ?: run { - android.util.Log.w("SpotiFLAC", "LRC sidecar: no SAF parent dir") - return false - } - val audioName = try { - DocumentFile.fromSingleUri(this, audioUri)?.name - } catch (_: Exception) { - null - } ?: return false - val baseName = audioName.substringBeforeLast('.', audioName) - val lrcName = "$baseName.lrc" - - val target = SafDownloadHandler.createOrReuseDocumentFile( - parent, - "application/octet-stream", - lrcName - ) ?: run { - android.util.Log.w("SpotiFLAC", "LRC sidecar: failed to create $lrcName") - return false - } - - contentResolver.openOutputStream(target.uri, "wt")?.use { output -> - output.write(lrcContent.toByteArray(Charsets.UTF_8)) - } ?: return false - android.util.Log.d("SpotiFLAC", "LRC sidecar written: $lrcName") - return true - } catch (e: Exception) { - android.util.Log.w("SpotiFLAC", "LRC sidecar write failed: ${e.message}") - return false - } - } - - /** - * Extract the audio filename referenced by a CUE sheet file. - * Reads the FILE "name" TYPE line from the .cue text. - * Returns just the filename (no path), or null if not found. - */ - private fun extractCueAudioFileName(cueTempPath: String): String? { - try { - val lines = File(cueTempPath).readLines() - for (line in lines) { - val trimmed = line.trim().let { l -> - if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l - } - if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) { - val rest = trimmed.substring(5).trim() - val filename = if (rest.startsWith("\"")) { - val endQuote = rest.indexOf('"', 1) - if (endQuote > 0) rest.substring(1, endQuote) else rest - } else { - val parts = rest.split("\\s+".toRegex()) - if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest - } - return filename.substringAfterLast("/").substringAfterLast("\\") - } - } - } catch (e: Exception) { - android.util.Log.w("SpotiFLAC", "Failed to extract audio filename from CUE: ${e.message}") - } - return null - } - - private val cueSiblingAudioExtensions = listOf( - ".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac" - ) - - // Audio file extensions that the local library scanner accepts. Must stay in - // sync with supportedAudioFormats in go_backend/library_scan.go so that every - // format the Go engine can read (FLAC, M4A/MP4/AAC, MP3, Opus/OGG, APE/WV/MPC, - // WAV, AIFF) is also enumerated here during the SAF folder walk. (.cue is - // handled separately.) - private val libraryScanAudioExtensions = setOf( - ".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg", - ".ape", ".wv", ".mpc", ".wav", ".aiff", ".aif" - ) - - private fun getSafChildFileLookup( - dir: DocumentFile, - cache: MutableMap>, - ): Map { - val dirKey = dir.uri.toString() - return cache.getOrPut(dirKey) { - try { - buildMap { - for (child in dir.listFiles()) { - if (!child.isFile) continue - val childName = child.name?.trim().orEmpty() - if (childName.isBlank()) continue - put(childName.lowercase(Locale.ROOT), child) - } - } - } catch (e: Exception) { - android.util.Log.w( - "SpotiFLAC", - "Failed to build SAF child lookup for $dirKey: ${e.message}", - ) - emptyMap() - } - } - } - - private fun resolveCueAudioSibling( - parentDir: DocumentFile, - cueName: String, - audioFileName: String?, - childLookupCache: MutableMap>, - ): DocumentFile? { - val childLookup = getSafChildFileLookup(parentDir, childLookupCache) - - val directMatch = audioFileName - ?.trim() - ?.takeIf { it.isNotEmpty() } - ?.substringAfterLast("/") - ?.substringAfterLast("\\") - ?.lowercase(Locale.ROOT) - ?.let(childLookup::get) - if (directMatch != null) { - return directMatch - } - - val cueBaseName = cueName.substringBeforeLast('.').trim() - if (cueBaseName.isBlank()) { - return null - } - - val cueBaseKey = cueBaseName.lowercase(Locale.ROOT) - for (ext in cueSiblingAudioExtensions) { - childLookup["$cueBaseKey$ext"]?.let { return it } - } - return null - } - - private fun scanSafTree(treeUriStr: String): Any { - if (treeUriStr.isBlank()) return "[]" - - val treeUri = Uri.parse(treeUriStr) - val root = DocumentFile.fromTreeUri(this, treeUri) ?: return "[]" - - resetSafScanProgress() - safScanCancel = false - safScanActive = true - updateSafScanProgress { - it.currentFile = "Scanning folders..." - } - - val supportedAudioExt = libraryScanAudioExtensions - val audioFiles = mutableListOf>() - val cueFiles = mutableListOf>() - val visitedDirUris = mutableSetOf() - val safChildLookupCache = mutableMapOf>() - var traversalErrors = 0 - - val queue: ArrayDeque> = ArrayDeque() - queue.add(root to "") - - while (queue.isNotEmpty()) { - if (safScanCancel) { - updateSafScanProgress { it.isComplete = true } - return "[]" - } - - val (dir, path) = queue.removeFirst() - val dirUri = dir.uri.toString() - if (!visitedDirUris.add(dirUri)) { - continue - } - - val children = try { - dir.listFiles() - } catch (e: Exception) { - traversalErrors++ - updateSafScanProgress { it.errorCount = traversalErrors } - android.util.Log.w( - "SpotiFLAC", - "SAF scan: failed listing directory $dirUri: ${e.message}", - ) - continue - } - - for (child in children) { - if (safScanCancel) { - updateSafScanProgress { it.isComplete = true } - return "[]" - } - - try { - if (child.isDirectory) { - val childName = child.name ?: continue - val childPath = if (path.isBlank()) childName else "$path/$childName" - val childUri = child.uri.toString() - if (childUri == dirUri || visitedDirUris.contains(childUri)) { - continue - } - queue.add(child to childPath) - } else if (child.isFile) { - val name = child.name ?: continue - val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) - if (ext == "cue") { - cueFiles.add(child to dir) - } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { - audioFiles.add(child to path) - } - } - } catch (e: Exception) { - traversalErrors++ - updateSafScanProgress { it.errorCount = traversalErrors } - android.util.Log.w( - "SpotiFLAC", - "SAF scan: skipped child under $dirUri: ${e.message}", - ) - } - } - } - - val totalItems = audioFiles.size + cueFiles.size - updateSafScanProgress { - it.totalFiles = totalItems - } - - if (audioFiles.isEmpty() && cueFiles.isEmpty()) { - updateSafScanProgress { - it.isComplete = true - it.progressPct = 100.0 - } - return "[]" - } - - // Stream results to a spill file: a full-library scan's JSONArray plus - // its serialized string would otherwise hold the whole payload on the - // Java heap several times over. - val spill = SpillJsonWriter() - var resultCount = 0 - fun putResult(obj: JSONObject) { - spill.raw(if (resultCount == 0) "[" else ",") - spill.raw(obj.toString()) - resultCount++ - } - var scanned = 0 - var errors = traversalErrors - - val cueReferencedAudioUris = mutableSetOf() - - for ((cueDoc, parentDir) in cueFiles) { - if (safScanCancel) { - updateSafScanProgress { it.isComplete = true } - spill.abandon() - return "[]" - } - - val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } - updateSafScanProgress { it.currentFile = cueName } - - var tempCuePath: String? = null - var tempAudioPath: String? = null - try { - tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") - if (tempCuePath == null) { - errors++ - android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy CUE ${cueDoc.uri}") - scanned++ - continue - } - - val audioFileName = extractCueAudioFileName(tempCuePath) - - val audioDoc = resolveCueAudioSibling( - parentDir = parentDir, - cueName = cueName, - audioFileName = audioFileName, - childLookupCache = safChildLookupCache, - ) - - if (audioDoc == null) { - android.util.Log.w("SpotiFLAC", "SAF scan: no audio file found for CUE $cueName") - errors++ - scanned++ - continue - } - - cueReferencedAudioUris.add(audioDoc.uri.toString()) - - val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath - val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } - val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) - val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null - val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueDoc.lastModified() } - val coverCacheKey = buildLibraryCoverCacheKey( - audioDoc.uri.toString(), - audioLastModified, - ) - - tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) - if (tempAudioPath == null) { - android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy audio for CUE $cueName") - errors++ - scanned++ - continue - } - - val renamedAudio = File(tempDir, audioName) - val tempAudioFile = File(tempAudioPath) - if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { - tempAudioFile.renameTo(renamedAudio) - tempAudioPath = renamedAudio.absolutePath - } - - val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L } - - val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey( - tempCuePath, - tempDir, - cueDoc.uri.toString(), - cueLastModified, - coverCacheKey, - ) - - val cueArray = JSONArray(cueResultsJson) - for (j in 0 until cueArray.length()) { - putResult(cueArray.getJSONObject(j)) - } - - android.util.Log.d( - "SpotiFLAC", - "SAF scan: CUE $cueName -> ${cueArray.length()} tracks" - ) - } catch (e: Exception) { - errors++ - android.util.Log.w("SpotiFLAC", "SAF scan: error processing CUE $cueName: ${e.message}") - } finally { - try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {} - try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {} - } - - scanned++ - val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 - updateSafScanProgress { - it.scannedFiles = scanned - it.errorCount = errors - it.progressPct = pct - } - } - - for ((doc, _) in audioFiles) { - if (safScanCancel) { - updateSafScanProgress { it.isComplete = true } - spill.abandon() - return "[]" - } - - if (cueReferencedAudioUris.contains(doc.uri.toString())) { - scanned++ - val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 - updateSafScanProgress { - it.scannedFiles = scanned - it.progressPct = pct - } - continue - } - - val name = try { doc.name ?: "" } catch (_: Exception) { "" } - updateSafScanProgress { - it.currentFile = name - } - - val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) - val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null - val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L } - val stableUri = doc.uri.toString() - val coverCacheKey = buildLibraryCoverCacheKey(stableUri, lastModified) - val metadataObj = readAudioMetadataFromUri( - doc.uri, - name, - fallbackExt, - coverCacheKey, - ) - if (metadataObj == null) { - errors++ - } else { - try { - metadataObj.put("id", buildStableLibraryId(stableUri)) - metadataObj.put("filePath", stableUri) - metadataObj.put("fileModTime", lastModified) - putResult(metadataObj) - } catch (_: Exception) { - errors++ - } - } - - scanned++ - val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 - updateSafScanProgress { - it.scannedFiles = scanned - it.errorCount = errors - it.progressPct = pct - } - } - - updateSafScanProgress { - it.isComplete = true - it.progressPct = 100.0 - } - - spill.raw(if (resultCount == 0) "[]" else "]") - return spill.result() - } - - /** - * Incremental SAF tree scan - only scans new or modified files. - * Supports .cue sheets: expands them into virtual track entries and - * deduplicates audio files referenced by CUE sheets. - * @param treeUriStr The SAF tree URI to scan - * @param existingFilesJson JSON object mapping file URI -> lastModified timestamp - * @return JSON object with new/changed files and removed URIs - */ - private fun scanSafTreeIncremental(treeUriStr: String, existingFilesJson: String): Any { - val existingFiles = mutableMapOf() - try { - val obj = JSONObject(existingFilesJson) - val keys = obj.keys() - while (keys.hasNext()) { - val key = keys.next() - existingFiles[key] = obj.optLong(key, 0) - } - } catch (_: Exception) {} - return scanSafTreeIncremental(treeUriStr, existingFiles) - } - - private fun scanSafTreeIncremental( - treeUriStr: String, - existingFiles: Map, - ): Any { - if (treeUriStr.isBlank()) { - val result = JSONObject() - result.put("files", JSONArray()) - result.put("removedUris", JSONArray()) - result.put("skippedCount", 0) - result.put("totalFiles", 0) - return result.toString() - } - - val treeUri = Uri.parse(treeUriStr) - val root = DocumentFile.fromTreeUri(this, treeUri) ?: run { - val result = JSONObject() - result.put("files", JSONArray()) - result.put("removedUris", JSONArray()) - result.put("skippedCount", 0) - result.put("totalFiles", 0) - return result.toString() - } - - resetSafScanProgress() - safScanCancel = false - safScanActive = true - updateSafScanProgress { - it.currentFile = "Scanning folders..." - } - - val supportedAudioExt = libraryScanAudioExtensions - val audioFiles = mutableListOf>() - val cueFilesToScan = mutableListOf>() - val unchangedCueFiles = mutableListOf>() - val currentUris = mutableSetOf() - val visitedDirUris = mutableSetOf() - val safChildLookupCache = mutableMapOf>() - var traversalErrors = 0 - - val existingCueVirtualPaths = mutableMapOf>() - for (key in existingFiles.keys) { - val hashIdx = key.indexOf("#track") - if (hashIdx > 0) { - val baseCueUri = key.substring(0, hashIdx) - existingCueVirtualPaths.getOrPut(baseCueUri) { mutableListOf() }.add(key) - } - } - - val queue: ArrayDeque> = ArrayDeque() - queue.add(root to "") - - while (queue.isNotEmpty()) { - if (safScanCancel) { - updateSafScanProgress { it.isComplete = true } - val result = JSONObject() - result.put("files", JSONArray()) - result.put("removedUris", JSONArray()) - result.put("skippedCount", 0) - result.put("totalFiles", 0) - result.put("cancelled", true) - return result.toString() - } - - val (dir, path) = queue.removeFirst() - val dirUri = dir.uri.toString() - if (!visitedDirUris.add(dirUri)) { - continue - } - - val children = try { - dir.listFiles() - } catch (e: Exception) { - traversalErrors++ - updateSafScanProgress { it.errorCount = traversalErrors } - android.util.Log.w( - "SpotiFLAC", - "SAF incremental scan: failed listing directory $dirUri: ${e.message}", - ) - continue - } - - for (child in children) { - if (safScanCancel) { - updateSafScanProgress { it.isComplete = true } - val result = JSONObject() - result.put("files", JSONArray()) - result.put("removedUris", JSONArray()) - result.put("skippedCount", 0) - result.put("totalFiles", 0) - result.put("cancelled", true) - return result.toString() - } - - try { - if (child.isDirectory) { - val childName = child.name ?: continue - val childPath = if (path.isBlank()) childName else "$path/$childName" - val childUri = child.uri.toString() - if (childUri == dirUri || visitedDirUris.contains(childUri)) { - continue - } - queue.add(child to childPath) - } else if (child.isFile) { - val uriStr = child.uri.toString() - currentUris.add(uriStr) - - val name = child.name ?: continue - val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) - - if (ext == "cue") { - val lastModified = try { - child.lastModified() - } catch (_: Exception) { 0L } - - val virtualPaths = existingCueVirtualPaths[uriStr] - val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] } - - if (existingModified != null && existingModified == lastModified) { - unchangedCueFiles.add(child to dir) - for (vp in virtualPaths) { - currentUris.add(vp) - } - } else { - cueFilesToScan.add(Triple(child, dir, lastModified)) - } - } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { - val existingModified = existingFiles[uriStr] - val lastModified = try { - child.lastModified() - } catch (_: Exception) { - existingModified ?: 0L - } - - if (existingModified == null || existingModified != lastModified) { - audioFiles.add(Triple(child, path, lastModified)) - } - } - } - } catch (e: Exception) { - traversalErrors++ - updateSafScanProgress { it.errorCount = traversalErrors } - android.util.Log.w( - "SpotiFLAC", - "SAF incremental scan: skipped child under $dirUri: ${e.message}", - ) - } - } - } - - val removedUris = existingFiles.keys.filter { !currentUris.contains(it) } - val totalFiles = currentUris.size - val filesToProcess = audioFiles.size + cueFilesToScan.size - val skippedCount = (totalFiles - filesToProcess).coerceAtLeast(0) - - updateSafScanProgress { - it.totalFiles = totalFiles - } - - if (audioFiles.isEmpty() && cueFilesToScan.isEmpty()) { - updateSafScanProgress { - it.isComplete = true - it.scannedFiles = totalFiles - it.progressPct = 100.0 - } - val result = JSONObject() - result.put("files", JSONArray()) - result.put("removedUris", JSONArray(removedUris)) - result.put("skippedCount", skippedCount) - result.put("totalFiles", totalFiles) - return result.toString() - } - - // Stream changed-file entries to a spill file — after a cache loss an - // incremental scan can be as large as a full scan. - val spill = SpillJsonWriter() - spill.raw("{\"files\":[") - var fileCount = 0 - fun putFile(obj: JSONObject) { - if (fileCount > 0) spill.raw(",") - spill.raw(obj.toString()) - fileCount++ - } - var scanned = 0 - var errors = traversalErrors - - val cueReferencedAudioUris = mutableSetOf() - - for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) { - if (safScanCancel) { - updateSafScanProgress { it.isComplete = true } - spill.abandon() - val result = JSONObject() - result.put("files", JSONArray()) - result.put("removedUris", JSONArray()) - result.put("skippedCount", skippedCount) - result.put("totalFiles", totalFiles) - result.put("cancelled", true) - return result.toString() - } - - val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } - updateSafScanProgress { it.currentFile = cueName } - - var tempCuePath: String? = null - var tempAudioPath: String? = null - try { - tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") - if (tempCuePath == null) { - errors++ - android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy CUE ${cueDoc.uri}") - scanned++ - continue - } - - val audioFileName = extractCueAudioFileName(tempCuePath) - - val audioDoc = resolveCueAudioSibling( - parentDir = parentDir, - cueName = cueName, - audioFileName = audioFileName, - childLookupCache = safChildLookupCache, - ) - - if (audioDoc == null) { - android.util.Log.w("SpotiFLAC", "SAF incremental scan: no audio file found for CUE $cueName") - errors++ - scanned++ - continue - } - - cueReferencedAudioUris.add(audioDoc.uri.toString()) - - val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath - val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } - val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) - val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null - val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueLastModified } - val coverCacheKey = buildLibraryCoverCacheKey( - audioDoc.uri.toString(), - audioLastModified, - ) - - tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) - if (tempAudioPath == null) { - android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy audio for CUE $cueName") - errors++ - scanned++ - continue - } - - val renamedAudio = File(tempDir, audioName) - val tempAudioFile = File(tempAudioPath) - if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { - tempAudioFile.renameTo(renamedAudio) - tempAudioPath = renamedAudio.absolutePath - } - - val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey( - tempCuePath, - tempDir, - cueDoc.uri.toString(), - cueLastModified, - coverCacheKey, - ) - - val cueArray = JSONArray(cueResultsJson) - for (j in 0 until cueArray.length()) { - val trackObj = cueArray.getJSONObject(j) - putFile(trackObj) - val virtualPath = trackObj.optString("filePath", "") - if (virtualPath.isNotBlank()) { - currentUris.add(virtualPath) - } - } - - android.util.Log.d( - "SpotiFLAC", - "SAF incremental scan: CUE $cueName -> ${cueArray.length()} tracks" - ) - } catch (e: Exception) { - errors++ - android.util.Log.w("SpotiFLAC", "SAF incremental scan: error processing CUE $cueName: ${e.message}") - } finally { - try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {} - try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {} - } - - scanned++ - val processed = skippedCount + scanned - val pct = if (totalFiles > 0) { - processed.toDouble() / totalFiles.toDouble() * 100.0 - } else { - 100.0 - } - updateSafScanProgress { - it.scannedFiles = processed - it.errorCount = errors - it.progressPct = pct - } - } - - for ((cueDoc, parentDir) in unchangedCueFiles) { - var tempCue: String? = null - try { - tempCue = copyUriToTemp(cueDoc.uri, ".cue") - if (tempCue != null) { - val audioFileName = extractCueAudioFileName(tempCue) - val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } - val audioDoc = resolveCueAudioSibling( - parentDir = parentDir, - cueName = cueName, - audioFileName = audioFileName, - childLookupCache = safChildLookupCache, - ) - if (audioDoc != null) { - cueReferencedAudioUris.add(audioDoc.uri.toString()) - } - } - } catch (e: Exception) { - android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to resolve audio for unchanged CUE: ${e.message}") - } finally { - try { tempCue?.let { File(it).delete() } } catch (_: Exception) {} - } - } - - for ((doc, _, lastModified) in audioFiles) { - if (safScanCancel) { - updateSafScanProgress { it.isComplete = true } - spill.abandon() - val result = JSONObject() - result.put("files", JSONArray()) - result.put("removedUris", JSONArray()) - result.put("skippedCount", skippedCount) - result.put("totalFiles", totalFiles) - result.put("cancelled", true) - return result.toString() - } - - if (cueReferencedAudioUris.contains(doc.uri.toString())) { - scanned++ - val processed = skippedCount + scanned - val pct = if (totalFiles > 0) { - processed.toDouble() / totalFiles.toDouble() * 100.0 - } else { - 100.0 - } - updateSafScanProgress { - it.scannedFiles = processed - it.progressPct = pct - } - continue - } - - val name = try { doc.name ?: "" } catch (_: Exception) { "" } - updateSafScanProgress { - it.currentFile = name - } - - val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) - val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null - val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified } - val stableUri = doc.uri.toString() - val coverCacheKey = buildLibraryCoverCacheKey(stableUri, safeLastModified) - val metadataObj = readAudioMetadataFromUri( - doc.uri, - name, - fallbackExt, - coverCacheKey, - ) - if (metadataObj == null) { - errors++ - } else { - try { - metadataObj.put("id", buildStableLibraryId(stableUri)) - metadataObj.put("filePath", stableUri) - metadataObj.put("fileModTime", safeLastModified) - metadataObj.put("lastModified", safeLastModified) - putFile(metadataObj) - } catch (_: Exception) { - errors++ - } - } - - scanned++ - val processed = skippedCount + scanned - val pct = if (totalFiles > 0) { - processed.toDouble() / totalFiles.toDouble() * 100.0 - } else { - 100.0 - } - updateSafScanProgress { - it.scannedFiles = processed - it.errorCount = errors - it.progressPct = pct - } - } - - val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) } - - updateSafScanProgress { - it.isComplete = true - it.progressPct = 100.0 - } - - spill.raw("],\"removedUris\":") - spill.raw(JSONArray(finalRemovedUris).toString()) - spill.raw(",\"skippedCount\":$skippedCount,\"totalFiles\":$totalFiles}") - return spill.result() - } - - /** - * Resolve SAF file last-modified values for a list of content URIs. - * Returns JSON object mapping uri -> lastModified (unix millis). - */ - private fun getSafFileModTimes(urisJson: String): String { - val result = JSONObject() - val uris = try { - JSONArray(urisJson) - } catch (_: Exception) { - JSONArray() - } - - for (i in 0 until uris.length()) { - val uriStr = uris.optString(i, "") - if (uriStr.isBlank()) continue - try { - val uri = Uri.parse(uriStr) - val doc = DocumentFile.fromSingleUri(this, uri) - if (doc != null && doc.exists()) { - result.put(uriStr, doc.lastModified()) - } - } catch (_: Exception) {} - } - - return result.toString() - } - - private fun runPostProcessingSafV2(fileUriStr: String, metadataJson: String): String { - val uri = Uri.parse(fileUriStr) - val doc = DocumentFile.fromSingleUri(this, uri) - ?: return errorJson("SAF file not found") - - val tempInput = copyUriToTemp(uri) ?: return errorJson("Failed to copy SAF file to temp") - val tempDir = File(tempInput).parentFile?.absolutePath ?: "" - if (tempDir.isNotBlank()) { - try { - Gobackend.allowDownloadDir(tempDir) - } catch (_: Exception) {} - } - - val inputObj = JSONObject() - inputObj.put("path", tempInput) - inputObj.put("uri", fileUriStr) - inputObj.put("name", doc.name ?: File(tempInput).name) - inputObj.put("mime_type", doc.type ?: contentResolver.getType(uri) ?: "") - inputObj.put("size", doc.length()) - inputObj.put("is_saf", true) - - val response = Gobackend.runPostProcessingV2JSON(inputObj.toString(), metadataJson) - val respObj = JSONObject(response) - if (!respObj.optBoolean("success", false)) { - try { - File(tempInput).delete() - } catch (_: Exception) {} - return response - } - - val newPath = respObj.optString("new_file_path", "") - val outputPath = if (newPath.isNotBlank()) newPath else tempInput - val outputFile = File(outputPath) - if (!outputFile.exists()) { - try { - File(tempInput).delete() - } catch (_: Exception) {} - respObj.put("success", false) - respObj.put("error", "postProcess output not found") - return respObj.toString() - } - - val newName = outputFile.name - if (!newName.isNullOrBlank() && doc.name != null && doc.name != newName) { - try { - doc.renameTo(newName) - } catch (_: Exception) {} - } - - val writeOk = writeUriFromPath(uri, outputFile.absolutePath) - if (!writeOk) { - respObj.put("success", false) - respObj.put("error", "failed to write postProcess output to SAF") - return respObj.toString() - } - - try { - if (outputPath != tempInput) { - outputFile.delete() - } - File(tempInput).delete() - } catch (_: Exception) {} - - respObj.put("new_file_path", uri.toString()) - respObj.put("file_path", uri.toString()) - return respObj.toString() - } - // Disable Flutter's built-in deep linking so that incoming ACTION_VIEW URLs // (Spotify, Deezer, Tidal, YouTube Music) are NOT forwarded to GoRouter. // We handle these URLs ourselves via receive_sharing_intent + ShareIntentService. diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt new file mode 100644 index 00000000..6ea2fbd8 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt @@ -0,0 +1,467 @@ +package com.zarz.spotiflac + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.documentfile.provider.DocumentFile +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode +import io.flutter.embedding.android.FlutterFragment +import io.flutter.embedding.android.RenderMode +import io.flutter.embedding.android.TransparencyMode +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.FlutterShellArgs +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel +import com.ryanheise.audioservice.AudioServicePlugin +import gobackend.Gobackend +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import org.json.JSONTokener +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.security.MessageDigest +import java.util.Locale + +// SAF/MediaStore URI IO helpers: temp copies, writes, sidecars, and the +// SAF post-processing pipeline. + +internal fun MainActivity.errorJson(message: String): String { + val obj = JSONObject() + obj.put("success", false) + obj.put("error", message) + obj.put("message", message) + return obj.toString() + } + + /** + * Detect whether a content URI belongs to the MediaStore provider. + * Samsung One UI may return MediaStore URIs from SAF tree traversal, + * which require READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission + * instead of SAF tree permission. + */ +internal fun MainActivity.isMediaStoreUri(uri: Uri): Boolean { + val authority = uri.authority ?: return false + return authority == "media" || + authority.startsWith("media.") || + authority.contains("media") + } + + /** + * Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE. + */ +internal fun MainActivity.resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String { + try { + contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val name = cursor.getString(0)?.lowercase(Locale.ROOT) ?: "" + val ext = extFromFileName(name) + if (ext.isNotBlank()) return ext + } + } + } catch (_: Exception) {} + + try { + val mime = contentResolver.getType(uri) + val ext = extFromMimeType(mime) + if (ext.isNotBlank()) return ext + } catch (_: Exception) {} + + return fallbackExt ?: "" + } + +internal fun MainActivity.extFromFileName(name: String): String { + return when { + name.endsWith(".m4a") -> ".m4a" + name.endsWith(".mp4") -> ".mp4" + name.endsWith(".aac") -> ".aac" + name.endsWith(".mp3") -> ".mp3" + name.endsWith(".opus") -> ".opus" + name.endsWith(".flac") -> ".flac" + name.endsWith(".ogg") -> ".ogg" + else -> "" + } + } + +internal fun MainActivity.extFromMimeType(mime: String?): String { + return when (mime) { + "audio/mp4" -> ".m4a" + "audio/aac" -> ".aac" + "audio/eac3" -> ".m4a" + "audio/ac3" -> ".m4a" + "audio/ac4" -> ".m4a" + "audio/mpeg" -> ".mp3" + "audio/ogg" -> ".opus" + "audio/flac" -> ".flac" + "audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave" -> ".wav" + "audio/aiff", "audio/x-aiff" -> ".aiff" + else -> "" + } + } + +internal fun MainActivity.copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? { + var tempFile: File? = null + var success = false + + try { + val mime = try { contentResolver.getType(uri) } catch (_: Exception) { null } + val nameHint = ( + try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null } + ?: uri.lastPathSegment + ?: "" + ).lowercase(Locale.ROOT) + val extFromName = extFromFileName(nameHint) + val extFromMime = extFromMimeType(mime) + val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "") + val suffix: String? = if (ext.isNotBlank()) ext else null + tempFile = File.createTempFile("saf_", suffix, cacheDir) + + contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(tempFile).use { output -> + input.copyTo(output) + } + } ?: return null + + success = true + return tempFile.absolutePath + } catch (e: SecurityException) { + // SAF permission denied - try MediaStore fallback for Samsung One UI + // which may return MediaStore URIs from SAF tree traversal + if (isMediaStoreUri(uri)) { + android.util.Log.d( + "SpotiFLAC", + "SAF denied for MediaStore URI, trying MediaStore fallback: $uri", + ) + val result = copyMediaStoreUriToTemp(uri, fallbackExt) + if (result != null) { + success = true + return result + } + } + android.util.Log.w( + "SpotiFLAC", + "SAF read denied for $uri: ${e.message}", + ) + return null + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "Failed copying SAF uri $uri to temp: ${e.message}", + ) + return null + } finally { + if (!success) { + try { + tempFile?.delete() + } catch (_: Exception) {} + } + } + } + + /** + * Fallback for Samsung One UI: read a MediaStore content URI using + * READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission instead of SAF. + * This handles the case where SAF tree traversal returns MediaStore URIs + * that the SAF document provider cannot access. + */ +internal fun MainActivity.copyMediaStoreUriToTemp(uri: Uri, fallbackExt: String?): String? { + var tempFile: File? = null + try { + val ext = resolveMediaStoreExt(uri, fallbackExt) + val suffix: String? = if (ext.isNotBlank()) ext else null + tempFile = File.createTempFile("ms_", suffix, cacheDir) + + contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(tempFile).use { output -> + input.copyTo(output) + } + } ?: run { + tempFile.delete() + return null + } + + android.util.Log.d( + "SpotiFLAC", + "MediaStore fallback succeeded for $uri", + ) + return tempFile.absolutePath + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "MediaStore fallback also failed for $uri: ${e.message}", + ) + try { tempFile?.delete() } catch (_: Exception) {} + return null + } + } + +internal fun MainActivity.buildUriDisplayName( + uri: Uri, + displayNameHint: String? = null, + fallbackExt: String? = null, + ): String { + val explicitName = displayNameHint?.trim().orEmpty() + if (explicitName.isNotEmpty()) return explicitName + + val docName = try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null } + val uriName = uri.lastPathSegment + val resolvedName = (docName ?: uriName ?: "").trim() + if (resolvedName.isNotEmpty()) return resolvedName + + val ext = when { + fallbackExt.isNullOrBlank().not() -> fallbackExt + isMediaStoreUri(uri) -> resolveMediaStoreExt(uri, fallbackExt) + else -> "" + } + return if (ext.isNullOrBlank()) "audio" else "audio$ext" + } + +internal fun MainActivity.buildLibraryCoverCacheKey(stablePath: String, lastModified: Long): String { + val normalizedPath = stablePath.trim() + if (normalizedPath.isEmpty()) return "" + return if (lastModified > 0L) "$normalizedPath|$lastModified" else normalizedPath + } + +internal fun MainActivity.readAudioMetadataFromUri( + uri: Uri, + displayNameHint: String? = null, + fallbackExt: String? = null, + coverCacheKey: String = "", + ): JSONObject? { + val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt) + + // Skip /proc/self/fd/ attempt when known to fail (e.g. Samsung SELinux). + if (procSelfFdReadable != false) { + try { + contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> + val directPath = "/proc/self/fd/${pfd.fd}" + val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON( + directPath, + displayName, + coverCacheKey, + ) + if (metadataJson.isNotBlank()) { + val obj = JSONObject(metadataJson) + val filenameFallback = obj.optBoolean("metadataFromFilename", false) + if (!obj.has("error") && !filenameFallback) { + procSelfFdReadable = true + return obj + } + // Go could not read real metadata from the fd path – + // remember so we skip the attempt for remaining files. + if (procSelfFdReadable == null) { + procSelfFdReadable = false + android.util.Log.d( + "SpotiFLAC", + "Direct /proc/self/fd read not usable on this device, " + + "using temp-file fallback for remaining files", + ) + } + } + } + } catch (e: Exception) { + if (procSelfFdReadable == null) { + procSelfFdReadable = false + android.util.Log.d( + "SpotiFLAC", + "Direct /proc/self/fd read not usable on this device, " + + "using temp-file fallback for remaining files", + ) + } + } + } + + val tempPath = try { + copyUriToTemp(uri, fallbackExt) + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "SAF metadata fallback copy failed for $uri: ${e.message}", + ) + null + } ?: return null + + try { + val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON( + tempPath, + displayName, + coverCacheKey, + ) + if (metadataJson.isBlank()) return null + val obj = JSONObject(metadataJson) + return if (obj.has("error")) null else obj + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "SAF metadata temp read failed for $uri: ${e.message}", + ) + return null + } finally { + try { + File(tempPath).delete() + } catch (_: Exception) {} + } + } + +internal fun MainActivity.writeUriFromPath(uri: Uri, srcPath: String): Boolean { + val srcFile = File(srcPath) + if (!srcFile.exists()) return false + contentResolver.openOutputStream(uri, "wt")?.use { output -> + FileInputStream(srcFile).use { input -> + input.copyTo(output) + } + } ?: return false + return true + } + + /** + * Get the parent DocumentFile directory for a SAF document URI. + * The child URI must be a tree-based document URI (e.g. from SAF tree scan). + * Returns a DocumentFile that supports findFile() for sibling lookup. + */ +internal fun MainActivity.safParentDir(childUri: Uri): DocumentFile? { + try { + val docId = android.provider.DocumentsContract.getDocumentId(childUri) + if (docId.isNullOrEmpty()) return null + val lastSlash = docId.lastIndexOf('/') + if (lastSlash <= 0) return null + + val parentDocId = docId.substring(0, lastSlash) + val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri) + if (treeDocId.isNullOrEmpty()) return null + + val parentUri = android.provider.DocumentsContract.buildDocumentUriUsingTree( + childUri, parentDocId + ) + return DocumentFile.fromTreeUri(this, parentUri) + ?: DocumentFile.fromSingleUri(this, parentUri) + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to get SAF parent dir: ${e.message}") + return null + } + } + + /** + * Write a ".lrc" sidecar next to a SAF audio document. The sidecar reuses + * the audio file's base name (e.g. "Song.flac" -> "Song.lrc") and is created + * in the same parent directory. Used by re-enrich when the user's lyrics + * mode requests an external/both sidecar. Best-effort: failures are logged + * and swallowed so they never abort the metadata enrichment itself. + */ +internal fun MainActivity.writeSafSidecarLrc(audioUri: Uri, lrcContent: String): Boolean { + if (lrcContent.isBlank()) return false + try { + val parent = safParentDir(audioUri) ?: run { + android.util.Log.w("SpotiFLAC", "LRC sidecar: no SAF parent dir") + return false + } + val audioName = try { + DocumentFile.fromSingleUri(this, audioUri)?.name + } catch (_: Exception) { + null + } ?: return false + val baseName = audioName.substringBeforeLast('.', audioName) + val lrcName = "$baseName.lrc" + + val target = SafDownloadHandler.createOrReuseDocumentFile( + parent, + "application/octet-stream", + lrcName + ) ?: run { + android.util.Log.w("SpotiFLAC", "LRC sidecar: failed to create $lrcName") + return false + } + + contentResolver.openOutputStream(target.uri, "wt")?.use { output -> + output.write(lrcContent.toByteArray(Charsets.UTF_8)) + } ?: return false + android.util.Log.d("SpotiFLAC", "LRC sidecar written: $lrcName") + return true + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "LRC sidecar write failed: ${e.message}") + return false + } + } + +internal fun MainActivity.runPostProcessingSafV2(fileUriStr: String, metadataJson: String): String { + val uri = Uri.parse(fileUriStr) + val doc = DocumentFile.fromSingleUri(this, uri) + ?: return errorJson("SAF file not found") + + val tempInput = copyUriToTemp(uri) ?: return errorJson("Failed to copy SAF file to temp") + val tempDir = File(tempInput).parentFile?.absolutePath ?: "" + if (tempDir.isNotBlank()) { + try { + Gobackend.allowDownloadDir(tempDir) + } catch (_: Exception) {} + } + + val inputObj = JSONObject() + inputObj.put("path", tempInput) + inputObj.put("uri", fileUriStr) + inputObj.put("name", doc.name ?: File(tempInput).name) + inputObj.put("mime_type", doc.type ?: contentResolver.getType(uri) ?: "") + inputObj.put("size", doc.length()) + inputObj.put("is_saf", true) + + val response = Gobackend.runPostProcessingV2JSON(inputObj.toString(), metadataJson) + val respObj = JSONObject(response) + if (!respObj.optBoolean("success", false)) { + try { + File(tempInput).delete() + } catch (_: Exception) {} + return response + } + + val newPath = respObj.optString("new_file_path", "") + val outputPath = if (newPath.isNotBlank()) newPath else tempInput + val outputFile = File(outputPath) + if (!outputFile.exists()) { + try { + File(tempInput).delete() + } catch (_: Exception) {} + respObj.put("success", false) + respObj.put("error", "postProcess output not found") + return respObj.toString() + } + + val newName = outputFile.name + if (!newName.isNullOrBlank() && doc.name != null && doc.name != newName) { + try { + doc.renameTo(newName) + } catch (_: Exception) {} + } + + val writeOk = writeUriFromPath(uri, outputFile.absolutePath) + if (!writeOk) { + respObj.put("success", false) + respObj.put("error", "failed to write postProcess output to SAF") + return respObj.toString() + } + + try { + if (outputPath != tempInput) { + outputFile.delete() + } + File(tempInput).delete() + } catch (_: Exception) {} + + respObj.put("new_file_path", uri.toString()) + respObj.put("file_path", uri.toString()) + return respObj.toString() + } + diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt new file mode 100644 index 00000000..7e3d33e5 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt @@ -0,0 +1,1004 @@ +package com.zarz.spotiflac + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.documentfile.provider.DocumentFile +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode +import io.flutter.embedding.android.FlutterFragment +import io.flutter.embedding.android.RenderMode +import io.flutter.embedding.android.TransparencyMode +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.FlutterShellArgs +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel +import com.ryanheise.audioservice.AudioServicePlugin +import gobackend.Gobackend +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import org.json.JSONTokener +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.security.MessageDigest +import java.util.Locale + +// SAF library-scan subsystem: tree walking, incremental diff, CUE resolution, +// and scan-progress state shared with the progress stream in MainActivity. + +internal fun MainActivity.buildStableLibraryId(filePath: String): String { + val digest = MessageDigest.getInstance("SHA-1") + val bytes = digest.digest(filePath.toByteArray(Charsets.UTF_8)) + val hex = bytes.joinToString("") { "%02x".format(it) } + return "lib_$hex" + } + +internal fun MainActivity.resetSafScanProgress() { + synchronized(safScanLock) { + safScanProgress = MainActivity.SafScanProgress() + } + // Allow re-probing /proc/self/fd readability on every new scan session. + procSelfFdReadable = null + } + +internal fun MainActivity.updateSafScanProgress(block: (MainActivity.SafScanProgress) -> Unit) { + synchronized(safScanLock) { + block(safScanProgress) + } + } + +internal fun MainActivity.safProgressToJson(): String { + val snapshot = synchronized(safScanLock) { safScanProgress.copy() } + val obj = JSONObject() + obj.put("total_files", snapshot.totalFiles) + obj.put("scanned_files", snapshot.scannedFiles) + obj.put("current_file", snapshot.currentFile) + obj.put("error_count", snapshot.errorCount) + obj.put("progress_pct", snapshot.progressPct) + obj.put("is_complete", snapshot.isComplete) + return obj.toString() + } + +internal fun MainActivity.readLibraryScanProgressJsonForStream(): String { + return if (safScanActive) { + safProgressToJson() + } else { + Gobackend.getLibraryScanProgressJSON() + } + } + + +internal fun MainActivity.loadExistingFilesFromSnapshot(snapshotPath: String): MutableMap { + val result = mutableMapOf() + if (snapshotPath.isBlank()) { + return result + } + + val snapshotFile = File(snapshotPath) + if (!snapshotFile.exists()) { + return result + } + + snapshotFile.forEachLine { line -> + if (line.isBlank()) return@forEachLine + val separatorIndex = line.indexOf('\t') + if (separatorIndex <= 0 || separatorIndex >= line.length - 1) { + return@forEachLine + } + val modTime = line.substring(0, separatorIndex).toLongOrNull() ?: 0L + val filePath = line.substring(separatorIndex + 1) + if (filePath.isNotEmpty()) { + result[filePath] = modTime + } + } + return result + } + +internal fun MainActivity.resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String { + val obj = JSONObject() + if (treeUriStr.isBlank() || fileName.isBlank()) { + obj.put("uri", "") + obj.put("relative_dir", "") + return obj.toString() + } + val safeRelativeDir = SafDownloadHandler.sanitizeRelativeDir(relativeDir) + val safeFileName = SafDownloadHandler.sanitizeFilename(fileName) + if (safeFileName.isBlank()) { + obj.put("uri", "") + obj.put("relative_dir", "") + return obj.toString() + } + + val treeUri = Uri.parse(treeUriStr) + val targetDir = SafDownloadHandler.findDocumentDir(this, treeUri, safeRelativeDir) + if (targetDir != null) { + val direct = targetDir.findFile(safeFileName) + if (direct != null && direct.isFile) { + obj.put("uri", direct.uri.toString()) + obj.put("relative_dir", safeRelativeDir) + return obj.toString() + } + } + + val root = DocumentFile.fromTreeUri(this, treeUri) ?: run { + obj.put("uri", "") + obj.put("relative_dir", "") + return obj.toString() + } + + val queue: ArrayDeque> = ArrayDeque() + queue.add(root to "") + var visited = 0 + val maxVisited = 20000 + + while (queue.isNotEmpty()) { + if (visited > maxVisited) break + val (dir, path) = queue.removeFirst() + for (child in dir.listFiles()) { + visited++ + if (child.isDirectory) { + val childName = child.name ?: continue + val childPath = if (path.isBlank()) childName else "$path/$childName" + queue.add(child to childPath) + } else if (child.isFile) { + if (child.name == safeFileName) { + obj.put("uri", child.uri.toString()) + obj.put("relative_dir", path) + return obj.toString() + } + } + } + } + + obj.put("uri", "") + obj.put("relative_dir", "") + return obj.toString() + } + + + /** + * Extract the audio filename referenced by a CUE sheet file. + * Reads the FILE "name" TYPE line from the .cue text. + * Returns just the filename (no path), or null if not found. + */ +internal fun MainActivity.extractCueAudioFileName(cueTempPath: String): String? { + try { + val lines = File(cueTempPath).readLines() + for (line in lines) { + val trimmed = line.trim().let { l -> + if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l + } + if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) { + val rest = trimmed.substring(5).trim() + val filename = if (rest.startsWith("\"")) { + val endQuote = rest.indexOf('"', 1) + if (endQuote > 0) rest.substring(1, endQuote) else rest + } else { + val parts = rest.split("\\s+".toRegex()) + if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest + } + return filename.substringAfterLast("/").substringAfterLast("\\") + } + } + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to extract audio filename from CUE: ${e.message}") + } + return null + } + + private val cueSiblingAudioExtensions = listOf( + ".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac" + ) + + // Audio file extensions that the local library scanner accepts. Must stay in + // sync with supportedAudioFormats in go_backend/library_scan.go so that every + // format the Go engine can read (FLAC, M4A/MP4/AAC, MP3, Opus/OGG, APE/WV/MPC, + // WAV, AIFF) is also enumerated here during the SAF folder walk. (.cue is + // handled separately.) + private val libraryScanAudioExtensions = setOf( + ".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg", + ".ape", ".wv", ".mpc", ".wav", ".aiff", ".aif" + ) + +internal fun MainActivity.getSafChildFileLookup( + dir: DocumentFile, + cache: MutableMap>, + ): Map { + val dirKey = dir.uri.toString() + return cache.getOrPut(dirKey) { + try { + buildMap { + for (child in dir.listFiles()) { + if (!child.isFile) continue + val childName = child.name?.trim().orEmpty() + if (childName.isBlank()) continue + put(childName.lowercase(Locale.ROOT), child) + } + } + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "Failed to build SAF child lookup for $dirKey: ${e.message}", + ) + emptyMap() + } + } + } + +internal fun MainActivity.resolveCueAudioSibling( + parentDir: DocumentFile, + cueName: String, + audioFileName: String?, + childLookupCache: MutableMap>, + ): DocumentFile? { + val childLookup = getSafChildFileLookup(parentDir, childLookupCache) + + val directMatch = audioFileName + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.substringAfterLast("/") + ?.substringAfterLast("\\") + ?.lowercase(Locale.ROOT) + ?.let(childLookup::get) + if (directMatch != null) { + return directMatch + } + + val cueBaseName = cueName.substringBeforeLast('.').trim() + if (cueBaseName.isBlank()) { + return null + } + + val cueBaseKey = cueBaseName.lowercase(Locale.ROOT) + for (ext in cueSiblingAudioExtensions) { + childLookup["$cueBaseKey$ext"]?.let { return it } + } + return null + } + +internal fun MainActivity.scanSafTree(treeUriStr: String): Any { + if (treeUriStr.isBlank()) return "[]" + + val treeUri = Uri.parse(treeUriStr) + val root = DocumentFile.fromTreeUri(this, treeUri) ?: return "[]" + + resetSafScanProgress() + safScanCancel = false + safScanActive = true + updateSafScanProgress { + it.currentFile = "Scanning folders..." + } + + val supportedAudioExt = libraryScanAudioExtensions + val audioFiles = mutableListOf>() + val cueFiles = mutableListOf>() + val visitedDirUris = mutableSetOf() + val safChildLookupCache = mutableMapOf>() + var traversalErrors = 0 + + val queue: ArrayDeque> = ArrayDeque() + queue.add(root to "") + + while (queue.isNotEmpty()) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + return "[]" + } + + val (dir, path) = queue.removeFirst() + val dirUri = dir.uri.toString() + if (!visitedDirUris.add(dirUri)) { + continue + } + + val children = try { + dir.listFiles() + } catch (e: Exception) { + traversalErrors++ + updateSafScanProgress { it.errorCount = traversalErrors } + android.util.Log.w( + "SpotiFLAC", + "SAF scan: failed listing directory $dirUri: ${e.message}", + ) + continue + } + + for (child in children) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + return "[]" + } + + try { + if (child.isDirectory) { + val childName = child.name ?: continue + val childPath = if (path.isBlank()) childName else "$path/$childName" + val childUri = child.uri.toString() + if (childUri == dirUri || visitedDirUris.contains(childUri)) { + continue + } + queue.add(child to childPath) + } else if (child.isFile) { + val name = child.name ?: continue + val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) + if (ext == "cue") { + cueFiles.add(child to dir) + } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { + audioFiles.add(child to path) + } + } + } catch (e: Exception) { + traversalErrors++ + updateSafScanProgress { it.errorCount = traversalErrors } + android.util.Log.w( + "SpotiFLAC", + "SAF scan: skipped child under $dirUri: ${e.message}", + ) + } + } + } + + val totalItems = audioFiles.size + cueFiles.size + updateSafScanProgress { + it.totalFiles = totalItems + } + + if (audioFiles.isEmpty() && cueFiles.isEmpty()) { + updateSafScanProgress { + it.isComplete = true + it.progressPct = 100.0 + } + return "[]" + } + + // Stream results to a spill file: a full-library scan's JSONArray plus + // its serialized string would otherwise hold the whole payload on the + // Java heap several times over. + val spill = this.SpillJsonWriter() + var resultCount = 0 + fun putResult(obj: JSONObject) { + spill.raw(if (resultCount == 0) "[" else ",") + spill.raw(obj.toString()) + resultCount++ + } + var scanned = 0 + var errors = traversalErrors + + val cueReferencedAudioUris = mutableSetOf() + + for ((cueDoc, parentDir) in cueFiles) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + spill.abandon() + return "[]" + } + + val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } + updateSafScanProgress { it.currentFile = cueName } + + var tempCuePath: String? = null + var tempAudioPath: String? = null + try { + tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") + if (tempCuePath == null) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy CUE ${cueDoc.uri}") + scanned++ + continue + } + + val audioFileName = extractCueAudioFileName(tempCuePath) + + val audioDoc = resolveCueAudioSibling( + parentDir = parentDir, + cueName = cueName, + audioFileName = audioFileName, + childLookupCache = safChildLookupCache, + ) + + if (audioDoc == null) { + android.util.Log.w("SpotiFLAC", "SAF scan: no audio file found for CUE $cueName") + errors++ + scanned++ + continue + } + + cueReferencedAudioUris.add(audioDoc.uri.toString()) + + val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath + val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } + val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) + val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null + val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueDoc.lastModified() } + val coverCacheKey = buildLibraryCoverCacheKey( + audioDoc.uri.toString(), + audioLastModified, + ) + + tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) + if (tempAudioPath == null) { + android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy audio for CUE $cueName") + errors++ + scanned++ + continue + } + + val renamedAudio = File(tempDir, audioName) + val tempAudioFile = File(tempAudioPath) + if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { + tempAudioFile.renameTo(renamedAudio) + tempAudioPath = renamedAudio.absolutePath + } + + val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L } + + val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey( + tempCuePath, + tempDir, + cueDoc.uri.toString(), + cueLastModified, + coverCacheKey, + ) + + val cueArray = JSONArray(cueResultsJson) + for (j in 0 until cueArray.length()) { + putResult(cueArray.getJSONObject(j)) + } + + android.util.Log.d( + "SpotiFLAC", + "SAF scan: CUE $cueName -> ${cueArray.length()} tracks" + ) + } catch (e: Exception) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF scan: error processing CUE $cueName: ${e.message}") + } finally { + try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {} + try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {} + } + + scanned++ + val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 + updateSafScanProgress { + it.scannedFiles = scanned + it.errorCount = errors + it.progressPct = pct + } + } + + for ((doc, _) in audioFiles) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + spill.abandon() + return "[]" + } + + if (cueReferencedAudioUris.contains(doc.uri.toString())) { + scanned++ + val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 + updateSafScanProgress { + it.scannedFiles = scanned + it.progressPct = pct + } + continue + } + + val name = try { doc.name ?: "" } catch (_: Exception) { "" } + updateSafScanProgress { + it.currentFile = name + } + + val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) + val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null + val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L } + val stableUri = doc.uri.toString() + val coverCacheKey = buildLibraryCoverCacheKey(stableUri, lastModified) + val metadataObj = readAudioMetadataFromUri( + doc.uri, + name, + fallbackExt, + coverCacheKey, + ) + if (metadataObj == null) { + errors++ + } else { + try { + metadataObj.put("id", buildStableLibraryId(stableUri)) + metadataObj.put("filePath", stableUri) + metadataObj.put("fileModTime", lastModified) + putResult(metadataObj) + } catch (_: Exception) { + errors++ + } + } + + scanned++ + val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 + updateSafScanProgress { + it.scannedFiles = scanned + it.errorCount = errors + it.progressPct = pct + } + } + + updateSafScanProgress { + it.isComplete = true + it.progressPct = 100.0 + } + + spill.raw(if (resultCount == 0) "[]" else "]") + return spill.result() + } + + /** + * Incremental SAF tree scan - only scans new or modified files. + * Supports .cue sheets: expands them into virtual track entries and + * deduplicates audio files referenced by CUE sheets. + * @param treeUriStr The SAF tree URI to scan + * @param existingFilesJson JSON object mapping file URI -> lastModified timestamp + * @return JSON object with new/changed files and removed URIs + */ +internal fun MainActivity.scanSafTreeIncremental(treeUriStr: String, existingFilesJson: String): Any { + val existingFiles = mutableMapOf() + try { + val obj = JSONObject(existingFilesJson) + val keys = obj.keys() + while (keys.hasNext()) { + val key = keys.next() + existingFiles[key] = obj.optLong(key, 0) + } + } catch (_: Exception) {} + return scanSafTreeIncremental(treeUriStr, existingFiles) + } + +internal fun MainActivity.scanSafTreeIncremental( + treeUriStr: String, + existingFiles: Map, + ): Any { + if (treeUriStr.isBlank()) { + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray()) + result.put("skippedCount", 0) + result.put("totalFiles", 0) + return result.toString() + } + + val treeUri = Uri.parse(treeUriStr) + val root = DocumentFile.fromTreeUri(this, treeUri) ?: run { + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray()) + result.put("skippedCount", 0) + result.put("totalFiles", 0) + return result.toString() + } + + resetSafScanProgress() + safScanCancel = false + safScanActive = true + updateSafScanProgress { + it.currentFile = "Scanning folders..." + } + + val supportedAudioExt = libraryScanAudioExtensions + val audioFiles = mutableListOf>() + val cueFilesToScan = mutableListOf>() + val unchangedCueFiles = mutableListOf>() + val currentUris = mutableSetOf() + val visitedDirUris = mutableSetOf() + val safChildLookupCache = mutableMapOf>() + var traversalErrors = 0 + + val existingCueVirtualPaths = mutableMapOf>() + for (key in existingFiles.keys) { + val hashIdx = key.indexOf("#track") + if (hashIdx > 0) { + val baseCueUri = key.substring(0, hashIdx) + existingCueVirtualPaths.getOrPut(baseCueUri) { mutableListOf() }.add(key) + } + } + + val queue: ArrayDeque> = ArrayDeque() + queue.add(root to "") + + while (queue.isNotEmpty()) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray()) + result.put("skippedCount", 0) + result.put("totalFiles", 0) + result.put("cancelled", true) + return result.toString() + } + + val (dir, path) = queue.removeFirst() + val dirUri = dir.uri.toString() + if (!visitedDirUris.add(dirUri)) { + continue + } + + val children = try { + dir.listFiles() + } catch (e: Exception) { + traversalErrors++ + updateSafScanProgress { it.errorCount = traversalErrors } + android.util.Log.w( + "SpotiFLAC", + "SAF incremental scan: failed listing directory $dirUri: ${e.message}", + ) + continue + } + + for (child in children) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray()) + result.put("skippedCount", 0) + result.put("totalFiles", 0) + result.put("cancelled", true) + return result.toString() + } + + try { + if (child.isDirectory) { + val childName = child.name ?: continue + val childPath = if (path.isBlank()) childName else "$path/$childName" + val childUri = child.uri.toString() + if (childUri == dirUri || visitedDirUris.contains(childUri)) { + continue + } + queue.add(child to childPath) + } else if (child.isFile) { + val uriStr = child.uri.toString() + currentUris.add(uriStr) + + val name = child.name ?: continue + val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) + + if (ext == "cue") { + val lastModified = try { + child.lastModified() + } catch (_: Exception) { 0L } + + val virtualPaths = existingCueVirtualPaths[uriStr] + val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] } + + if (existingModified != null && existingModified == lastModified) { + unchangedCueFiles.add(child to dir) + for (vp in virtualPaths) { + currentUris.add(vp) + } + } else { + cueFilesToScan.add(Triple(child, dir, lastModified)) + } + } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { + val existingModified = existingFiles[uriStr] + val lastModified = try { + child.lastModified() + } catch (_: Exception) { + existingModified ?: 0L + } + + if (existingModified == null || existingModified != lastModified) { + audioFiles.add(Triple(child, path, lastModified)) + } + } + } + } catch (e: Exception) { + traversalErrors++ + updateSafScanProgress { it.errorCount = traversalErrors } + android.util.Log.w( + "SpotiFLAC", + "SAF incremental scan: skipped child under $dirUri: ${e.message}", + ) + } + } + } + + val removedUris = existingFiles.keys.filter { !currentUris.contains(it) } + val totalFiles = currentUris.size + val filesToProcess = audioFiles.size + cueFilesToScan.size + val skippedCount = (totalFiles - filesToProcess).coerceAtLeast(0) + + updateSafScanProgress { + it.totalFiles = totalFiles + } + + if (audioFiles.isEmpty() && cueFilesToScan.isEmpty()) { + updateSafScanProgress { + it.isComplete = true + it.scannedFiles = totalFiles + it.progressPct = 100.0 + } + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray(removedUris)) + result.put("skippedCount", skippedCount) + result.put("totalFiles", totalFiles) + return result.toString() + } + + // Stream changed-file entries to a spill file — after a cache loss an + // incremental scan can be as large as a full scan. + val spill = this.SpillJsonWriter() + spill.raw("{\"files\":[") + var fileCount = 0 + fun putFile(obj: JSONObject) { + if (fileCount > 0) spill.raw(",") + spill.raw(obj.toString()) + fileCount++ + } + var scanned = 0 + var errors = traversalErrors + + val cueReferencedAudioUris = mutableSetOf() + + for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + spill.abandon() + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray()) + result.put("skippedCount", skippedCount) + result.put("totalFiles", totalFiles) + result.put("cancelled", true) + return result.toString() + } + + val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } + updateSafScanProgress { it.currentFile = cueName } + + var tempCuePath: String? = null + var tempAudioPath: String? = null + try { + tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") + if (tempCuePath == null) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy CUE ${cueDoc.uri}") + scanned++ + continue + } + + val audioFileName = extractCueAudioFileName(tempCuePath) + + val audioDoc = resolveCueAudioSibling( + parentDir = parentDir, + cueName = cueName, + audioFileName = audioFileName, + childLookupCache = safChildLookupCache, + ) + + if (audioDoc == null) { + android.util.Log.w("SpotiFLAC", "SAF incremental scan: no audio file found for CUE $cueName") + errors++ + scanned++ + continue + } + + cueReferencedAudioUris.add(audioDoc.uri.toString()) + + val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath + val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } + val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) + val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null + val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueLastModified } + val coverCacheKey = buildLibraryCoverCacheKey( + audioDoc.uri.toString(), + audioLastModified, + ) + + tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) + if (tempAudioPath == null) { + android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy audio for CUE $cueName") + errors++ + scanned++ + continue + } + + val renamedAudio = File(tempDir, audioName) + val tempAudioFile = File(tempAudioPath) + if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { + tempAudioFile.renameTo(renamedAudio) + tempAudioPath = renamedAudio.absolutePath + } + + val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey( + tempCuePath, + tempDir, + cueDoc.uri.toString(), + cueLastModified, + coverCacheKey, + ) + + val cueArray = JSONArray(cueResultsJson) + for (j in 0 until cueArray.length()) { + val trackObj = cueArray.getJSONObject(j) + putFile(trackObj) + val virtualPath = trackObj.optString("filePath", "") + if (virtualPath.isNotBlank()) { + currentUris.add(virtualPath) + } + } + + android.util.Log.d( + "SpotiFLAC", + "SAF incremental scan: CUE $cueName -> ${cueArray.length()} tracks" + ) + } catch (e: Exception) { + errors++ + android.util.Log.w("SpotiFLAC", "SAF incremental scan: error processing CUE $cueName: ${e.message}") + } finally { + try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {} + try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {} + } + + scanned++ + val processed = skippedCount + scanned + val pct = if (totalFiles > 0) { + processed.toDouble() / totalFiles.toDouble() * 100.0 + } else { + 100.0 + } + updateSafScanProgress { + it.scannedFiles = processed + it.errorCount = errors + it.progressPct = pct + } + } + + for ((cueDoc, parentDir) in unchangedCueFiles) { + var tempCue: String? = null + try { + tempCue = copyUriToTemp(cueDoc.uri, ".cue") + if (tempCue != null) { + val audioFileName = extractCueAudioFileName(tempCue) + val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } + val audioDoc = resolveCueAudioSibling( + parentDir = parentDir, + cueName = cueName, + audioFileName = audioFileName, + childLookupCache = safChildLookupCache, + ) + if (audioDoc != null) { + cueReferencedAudioUris.add(audioDoc.uri.toString()) + } + } + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to resolve audio for unchanged CUE: ${e.message}") + } finally { + try { tempCue?.let { File(it).delete() } } catch (_: Exception) {} + } + } + + for ((doc, _, lastModified) in audioFiles) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + spill.abandon() + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray()) + result.put("skippedCount", skippedCount) + result.put("totalFiles", totalFiles) + result.put("cancelled", true) + return result.toString() + } + + if (cueReferencedAudioUris.contains(doc.uri.toString())) { + scanned++ + val processed = skippedCount + scanned + val pct = if (totalFiles > 0) { + processed.toDouble() / totalFiles.toDouble() * 100.0 + } else { + 100.0 + } + updateSafScanProgress { + it.scannedFiles = processed + it.progressPct = pct + } + continue + } + + val name = try { doc.name ?: "" } catch (_: Exception) { "" } + updateSafScanProgress { + it.currentFile = name + } + + val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) + val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null + val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified } + val stableUri = doc.uri.toString() + val coverCacheKey = buildLibraryCoverCacheKey(stableUri, safeLastModified) + val metadataObj = readAudioMetadataFromUri( + doc.uri, + name, + fallbackExt, + coverCacheKey, + ) + if (metadataObj == null) { + errors++ + } else { + try { + metadataObj.put("id", buildStableLibraryId(stableUri)) + metadataObj.put("filePath", stableUri) + metadataObj.put("fileModTime", safeLastModified) + metadataObj.put("lastModified", safeLastModified) + putFile(metadataObj) + } catch (_: Exception) { + errors++ + } + } + + scanned++ + val processed = skippedCount + scanned + val pct = if (totalFiles > 0) { + processed.toDouble() / totalFiles.toDouble() * 100.0 + } else { + 100.0 + } + updateSafScanProgress { + it.scannedFiles = processed + it.errorCount = errors + it.progressPct = pct + } + } + + val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) } + + updateSafScanProgress { + it.isComplete = true + it.progressPct = 100.0 + } + + spill.raw("],\"removedUris\":") + spill.raw(JSONArray(finalRemovedUris).toString()) + spill.raw(",\"skippedCount\":$skippedCount,\"totalFiles\":$totalFiles}") + return spill.result() + } + + /** + * Resolve SAF file last-modified values for a list of content URIs. + * Returns JSON object mapping uri -> lastModified (unix millis). + */ +internal fun MainActivity.getSafFileModTimes(urisJson: String): String { + val result = JSONObject() + val uris = try { + JSONArray(urisJson) + } catch (_: Exception) { + JSONArray() + } + + for (i in 0 until uris.length()) { + val uriStr = uris.optString(i, "") + if (uriStr.isBlank()) continue + try { + val uri = Uri.parse(uriStr) + val doc = DocumentFile.fromSingleUri(this, uri) + if (doc != null && doc.exists()) { + result.put(uriStr, doc.lastModified()) + } + } catch (_: Exception) {} + } + + return result.toString() + } +