diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt index 115b7184..2546feeb 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt @@ -6,6 +6,9 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.ParcelFileDescriptor +import android.system.Os +import android.system.OsConstants import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile @@ -137,7 +140,7 @@ internal fun MainActivity.copyUriToTemp(uri: Uri, fallbackExt: String? = null): contentResolver.openInputStream(uri)?.use { input -> FileOutputStream(tempFile).use { output -> - input.copyTo(output) + input.copyTo(output, bufferSize = 256 * 1024) } } ?: return null @@ -192,7 +195,7 @@ internal fun MainActivity.copyMediaStoreUriToTemp(uri: Uri, fallbackExt: String? contentResolver.openInputStream(uri)?.use { input -> FileOutputStream(tempFile).use { output -> - input.copyTo(output) + input.copyTo(output, bufferSize = 256 * 1024) } } ?: run { tempFile.delete() @@ -241,6 +244,20 @@ internal fun MainActivity.buildLibraryCoverCacheKey(stablePath: String, lastModi return if (lastModified > 0L) "$normalizedPath|$lastModified" else normalizedPath } +private fun isSeekableSafDescriptor(descriptor: ParcelFileDescriptor): Boolean { + return try { + val position = Os.lseek( + descriptor.fileDescriptor, + 0L, + OsConstants.SEEK_CUR, + ) + Os.lseek(descriptor.fileDescriptor, position, OsConstants.SEEK_SET) + true + } catch (_: Exception) { + false + } +} + internal fun MainActivity.readAudioMetadataFromUri( uri: Uri, displayNameHint: String? = null, @@ -253,6 +270,13 @@ internal fun MainActivity.readAudioMetadataFromUri( if (procSelfFdReadable != false) { try { contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> + if (!isSeekableSafDescriptor(pfd)) { + synchronized(procSelfFdStateLock) { + procSelfFdReadable = false + procSelfFdFallbacks = 0 + } + return@use + } val directPath = "/proc/self/fd/${pfd.fd}" val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON( directPath, @@ -334,7 +358,7 @@ internal fun MainActivity.writeUriFromPath(uri: Uri, srcPath: String): Boolean { if (!srcFile.exists()) return false contentResolver.openOutputStream(uri, "wt")?.use { output -> FileInputStream(srcFile).use { input -> - input.copyTo(output) + input.copyTo(output, bufferSize = 256 * 1024) } } ?: return false return true diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt index f0442299..9fabdb69 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt @@ -8,6 +8,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document.MIME_TYPE_DIR import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile @@ -437,6 +438,38 @@ private fun loadSafScanCheckpoint(path: String): MutableMap { return entries } +private fun reconcileSafScanCheckpoint( + outputPath: String, + loaded: MutableMap, +): MutableMap { + val output = File(outputPath) + if (!output.exists()) return loaded + + val outputEntries = mutableMapOf() + try { + output.forEachLine(Charsets.UTF_8) { line -> + if (line.isBlank()) return@forEachLine + try { + val obj = JSONObject(line) + val filePath = obj.optString("filePath", "").trim() + if (filePath.isNotBlank()) { + val modified = obj.optLong("fileModTime", 0L) + val cueMarker = filePath.indexOf("#track") + val key = if (cueMarker > 0) filePath.substring(0, cueMarker) else filePath + outputEntries[key] = modified + } + } catch (_: Exception) {} + } + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "SAF scan: failed reconciling checkpoint: ${e.message}") + return loaded + } + + loaded.clear() + loaded.putAll(outputEntries) + return loaded +} + private fun countSafScanRows(path: String): Int { val output = File(path) if (!output.exists()) return 0 @@ -523,10 +556,10 @@ internal fun MainActivity.getSafChildFileLookup( return cache.getOrPut(dirKey) { buildMap { for (child in listSafChildrenOrThrow(dir)) { - if (!child.isFile) continue + if (child.isDirectory) continue val childName = child.name?.trim().orEmpty() if (childName.isBlank()) continue - put(childName.lowercase(Locale.ROOT), child) + put(childName.lowercase(Locale.ROOT), child.doc) } } } @@ -563,22 +596,49 @@ internal fun MainActivity.resolveCueAudioSibling( return null } -internal fun MainActivity.listSafChildrenOrThrow(dir: DocumentFile): List { +internal data class SafChildEntry( + val doc: DocumentFile, + val name: String?, + val isDirectory: Boolean, + val lastModified: Long, +) + +internal fun MainActivity.listSafChildrenOrThrow(dir: DocumentFile): List { val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( dir.uri, DocumentsContract.getDocumentId(dir.uri), ) - val cursor = contentResolver.query( - childrenUri, - arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), - null, - null, - null, - ) ?: throw IOException("SAF provider returned no cursor for ${dir.uri}") + val projection = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + ) + val cursor = try { + contentResolver.query(childrenUri, projection, null, null, null) + } catch (_: Exception) { + // A few older providers reject the richer projection; retry with IDs. + contentResolver.query( + childrenUri, + arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), + null, + null, + null, + ) + } ?: throw IOException("SAF provider returned no cursor for ${dir.uri}") return cursor.use { val documentIdIndex = it.getColumnIndexOrThrow( DocumentsContract.Document.COLUMN_DOCUMENT_ID, ) + val displayNameIndex = it.getColumnIndex( + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + ) + val mimeTypeIndex = it.getColumnIndex( + DocumentsContract.Document.COLUMN_MIME_TYPE, + ) + val lastModifiedIndex = it.getColumnIndex( + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + ) buildList { while (it.moveToNext()) { val childUri = DocumentsContract.buildDocumentUriUsingTree( @@ -587,7 +647,29 @@ internal fun MainActivity.listSafChildrenOrThrow(dir: DocumentFile): List= 0 && !it.isNull(displayNameIndex)) { + it.getString(displayNameIndex) + } else { + try { child.name } catch (_: Exception) { null } + } + val mimeType = if (mimeTypeIndex >= 0 && !it.isNull(mimeTypeIndex)) { + it.getString(mimeTypeIndex) + } else { + null + } + val isDirectory = if (mimeType != null) { + mimeType == MIME_TYPE_DIR + } else { + try { child.isDirectory } catch (_: Exception) { false } + } + val lastModified = if ( + lastModifiedIndex >= 0 && !it.isNull(lastModifiedIndex) + ) { + it.getLong(lastModifiedIndex) + } else { + try { child.lastModified() } catch (_: Exception) { 0L } + } + add(SafChildEntry(child, name, isDirectory, lastModified)) } } } @@ -623,8 +705,18 @@ internal fun MainActivity.scanSafTree( treeUriStr: String, ndjsonOutputPath: String? = null, ): Any { + val earlyCheckpoint = ndjsonOutputPath + ?.takeIf { + File(it).exists() && File(safScanCheckpointPath(it)).exists() + } + ?.let { loadSafScanCheckpoint(safScanCheckpointPath(it)) } + ?: mutableMapOf() + fun emptyResult(): Any { if (ndjsonOutputPath == null) return "[]" + if (earlyCheckpoint.isNotEmpty() && File(ndjsonOutputPath).exists()) { + throw IOException("SAF scan returned no files while a resumable scan exists") + } File(ndjsonOutputPath).writeText("", Charsets.UTF_8) try { File(safScanCheckpointPath(ndjsonOutputPath)).delete() } catch (_: Exception) {} return mapOf("path" to ndjsonOutputPath, "count" to 0) @@ -643,12 +735,28 @@ internal fun MainActivity.scanSafTree( safScanCancel = false safScanActive = true updateSafScanProgress { - it.currentFile = "Scanning folders..." + it.scannedFiles = earlyCheckpoint.size + it.currentFile = if (earlyCheckpoint.isEmpty()) { + "Scanning folders..." + } else { + "Resuming scan..." + } } val supportedAudioExt = libraryScanAudioExtensions - val audioFiles = mutableListOf>() - val cueFiles = mutableListOf>() + data class SafAudioEntry( + val doc: DocumentFile, + val name: String, + val lastModified: Long, + ) + data class SafCueEntry( + val doc: DocumentFile, + val parentDir: DocumentFile, + val name: String, + val lastModified: Long, + ) + val audioFiles = mutableListOf() + val cueFiles = mutableListOf() val visitedDirUris = mutableSetOf() val safChildLookupCache = mutableMapOf>() var traversalErrors = 0 @@ -688,18 +796,19 @@ internal fun MainActivity.scanSafTree( if (child.isDirectory) { val childName = child.name ?: continue val childPath = if (path.isBlank()) childName else "$path/$childName" - val childUri = child.uri.toString() + val childUri = child.doc.uri.toString() if (childUri == dirUri || visitedDirUris.contains(childUri)) { continue } - queue.add(child to childPath) - } else if (child.isFile) { + queue.add(child.doc to childPath) + } else { val name = child.name ?: continue + val lastModified = child.lastModified val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) if (ext == "cue") { - cueFiles.add(child to dir) + cueFiles.add(SafCueEntry(child.doc, dir, name, lastModified)) } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { - audioFiles.add(child to path) + audioFiles.add(SafAudioEntry(child.doc, name, lastModified)) } } } catch (e: Exception) { @@ -720,6 +829,10 @@ internal fun MainActivity.scanSafTree( val totalItems = audioFiles.size + cueFiles.size updateSafScanProgress { it.totalFiles = totalItems + if (totalItems > 0 && earlyCheckpoint.isNotEmpty()) { + it.scannedFiles = earlyCheckpoint.size.coerceAtMost(totalItems) + it.progressPct = it.scannedFiles.toDouble() / totalItems.toDouble() * 100.0 + } } if (audioFiles.isEmpty() && cueFiles.isEmpty()) { @@ -747,7 +860,10 @@ internal fun MainActivity.scanSafTree( try { checkpointFile.delete() } catch (_: Exception) {} } repairSafScanOutput(outputFile.absolutePath) - loadSafScanCheckpoint(checkpointPath) + reconcileSafScanCheckpoint( + outputFile.absolutePath, + loadSafScanCheckpoint(checkpointPath), + ) } else { mutableMapOf() } @@ -782,19 +898,23 @@ internal fun MainActivity.scanSafTree( resultCount++ } try { - var scanned = 0 + var scanned = checkpoint.size.coerceAtMost(totalItems) var errors = traversalErrors val cueReferencedAudioUris = mutableSetOf() - for ((cueDoc, parentDir) in cueFiles) { + for (cue in cueFiles) { + val cueDoc = cue.doc + val parentDir = cue.parentDir + val cueUri = cueDoc.uri.toString() + val cueAlreadyIndexed = checkpointed(cueUri, cue.lastModified) if (safScanCancel) { ndjsonWriter?.close() spill?.abandon() return cancelledResult() } - val cueName = try { cueDoc.name ?: "" } catch (_: Exception) { "" } + val cueName = cue.name updateSafScanProgress { it.currentFile = cueName } var tempCuePath: String? = null @@ -826,6 +946,10 @@ internal fun MainActivity.scanSafTree( cueReferencedAudioUris.add(audioDoc.uri.toString()) + if (cueAlreadyIndexed) { + continue + } + 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) @@ -851,7 +975,7 @@ internal fun MainActivity.scanSafTree( tempAudioPath = renamedAudio.absolutePath } - val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L } + val cueLastModified = cue.lastModified val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey( tempCuePath, @@ -865,6 +989,8 @@ internal fun MainActivity.scanSafTree( for (j in 0 until cueArray.length()) { putResult(cueArray.getJSONObject(j)) } + ndjsonWriter?.flush() + recordCheckpoint(cueUri, cue.lastModified) } catch (e: Exception) { errors++ @@ -890,9 +1016,10 @@ internal fun MainActivity.scanSafTree( val metadata: JSONObject?, ) - val pendingAudio = mutableListOf>() + val pendingAudio = mutableListOf() // Skip resumable and CUE entries before parallel reads. - for ((doc, _) in audioFiles) { + for (audio in audioFiles) { + val doc = audio.doc if (safScanCancel) { ndjsonWriter?.close() spill?.abandon() @@ -900,13 +1027,12 @@ internal fun MainActivity.scanSafTree( } val stableUri = doc.uri.toString() - val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L } - if (checkpointed(stableUri, lastModified) || - cueReferencedAudioUris.contains(stableUri) - ) { - if (!checkpointed(stableUri, lastModified)) { - recordCheckpoint(stableUri, lastModified) - } + val lastModified = audio.lastModified + if (checkpointed(stableUri, lastModified)) { + continue + } + if (cueReferencedAudioUris.contains(stableUri)) { + recordCheckpoint(stableUri, lastModified) scanned++ val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 updateSafScanProgress { @@ -915,7 +1041,7 @@ internal fun MainActivity.scanSafTree( } continue } - pendingAudio.add(Triple(doc, stableUri, lastModified)) + pendingAudio.add(audio) } // Bound parallelism to limit SAF full-copy memory and I/O. @@ -929,9 +1055,12 @@ internal fun MainActivity.scanSafTree( return cancelledResult() } - val futures = batch.map { (doc, stableUri, lastModified) -> + val futures = batch.map { audio -> executor.submit { - val name = try { doc.name ?: "" } catch (_: Exception) { "" } + val doc = audio.doc + val stableUri = doc.uri.toString() + val name = audio.name + val lastModified = audio.lastModified val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null val coverCacheKey = buildLibraryCoverCacheKey(stableUri, lastModified) @@ -1122,44 +1251,38 @@ internal fun MainActivity.scanSafTreeIncremental( if (child.isDirectory) { val childName = child.name ?: continue val childPath = if (path.isBlank()) childName else "$path/$childName" - val childUri = child.uri.toString() + val childUri = child.doc.uri.toString() if (childUri == dirUri || visitedDirUris.contains(childUri)) { continue } - queue.add(child to childPath) - } else if (child.isFile) { - val uriStr = child.uri.toString() + queue.add(child.doc to childPath) + } else { + val uriStr = child.doc.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 lastModified = child.lastModified val virtualPaths = existingCueVirtualPaths[uriStr] val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] } if (existingModified != null && existingModified == lastModified) { - unchangedCueFiles.add(child to dir) + unchangedCueFiles.add(child.doc to dir) for (vp in virtualPaths) { currentUris.add(vp) } } else { - cueFilesToScan.add(Triple(child, dir, lastModified)) + cueFilesToScan.add(Triple(child.doc, dir, lastModified)) } } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { val existingModified = existingFiles[uriStr] - val lastModified = try { - child.lastModified() - } catch (_: Exception) { - existingModified ?: 0L - } + val lastModified = child.lastModified if (existingModified == null || existingModified != lastModified) { - audioFiles.add(Triple(child, path, lastModified)) + audioFiles.add(Triple(child.doc, path, lastModified)) } } } diff --git a/lib/main.dart b/lib/main.dart index 4b36a9be..d4f1b801 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -357,6 +357,13 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> final now = DateTime.now(); final prefs = await SharedPreferences.getInstance(); + final pendingSourceId = prefs.getString(localLibraryActiveScanSourceKey); + if (pendingSourceId != null && pendingSourceId.trim().isNotEmpty) { + await ref + .read(localLibraryProvider.notifier) + .startSourceScan(pendingSourceId.trim()); + return; + } final lastScanned = readLocalLibraryLastScannedAt(prefs); if (lastScanned != null) { diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 33645c13..1445fd51 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -531,6 +531,7 @@ class LocalLibraryNotifier extends Notifier { folderPath, isCancelled: () => _scanCancelRequested, ); + var ingested = false; try { if (_scanCancelRequested) return null; state = state.copyWith( @@ -560,9 +561,10 @@ class LocalLibraryNotifier extends Notifier { 'Stream-ingested ${result.inserted}/${scanFile.expectedCount} scan rows ' '(${result.skipped} downloads excluded)', ); + ingested = true; return result; } finally { - await scanFile.delete(); + if (ingested) await scanFile.delete(); } } @@ -600,6 +602,12 @@ class LocalLibraryNotifier extends Notifier { _scanInProgress = true; _scanCancelRequested = false; + try { + final prefs = await _prefs; + await prefs.setString(localLibraryActiveScanSourceKey, activeSourceId); + } catch (e) { + _log.w('Failed to persist active library scan marker: $e'); + } _log.i( 'Starting library scan: $folderPath (incremental: ${!forceFullScan})', ); @@ -930,6 +938,15 @@ class LocalLibraryNotifier extends Notifier { } _stopProgressPolling(); _scanInProgress = false; + try { + final prefs = await _prefs; + if (prefs.getString(localLibraryActiveScanSourceKey) == + activeSourceId) { + await prefs.remove(localLibraryActiveScanSourceKey); + } + } catch (e) { + _log.w('Failed to clear active library scan marker: $e'); + } state = state.copyWith(clearScanningSourceId: true); } } diff --git a/lib/utils/local_library_scan_prefs.dart b/lib/utils/local_library_scan_prefs.dart index 1d544f8d..859e55a2 100644 --- a/lib/utils/local_library_scan_prefs.dart +++ b/lib/utils/local_library_scan_prefs.dart @@ -1,6 +1,7 @@ import 'package:shared_preferences/shared_preferences.dart'; const localLibraryLastScannedAtKey = 'local_library_last_scanned_at'; +const localLibraryActiveScanSourceKey = 'local_library_active_scan_source'; DateTime? readLocalLibraryLastScannedAt(SharedPreferences prefs) { final lastScannedAtStr = prefs.getString(localLibraryLastScannedAtKey);