From 8758790d9f4990aaa250f3d0db4d17b125c83783 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:44:29 +0700 Subject: [PATCH] fix(download): preserve native media workspace across cache eviction --- .../com/zarz/spotiflac/DownloadService.kt | 20 ++++- .../zarz/spotiflac/NativeMediaWorkspace.kt | 25 ++++++ .../com/zarz/spotiflac/SelectedCoreBackend.kt | 17 ++-- .../spotiflac/NativeMediaWorkspaceTest.kt | 77 +++++++++++++++++++ ...download_queue_provider_native_worker.dart | 6 ++ 5 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 android/app/src/main/kotlin/com/zarz/spotiflac/NativeMediaWorkspace.kt create mode 100644 android/app/src/test/kotlin/com/zarz/spotiflac/NativeMediaWorkspaceTest.kt diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt index f891ad4b..cf3f167a 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -1092,6 +1092,7 @@ class DownloadService : Service() { var progressInitialized = false var retryCurrentRequest = false var directoryScope: AutoCloseable? = null + var failureStage = "preparing output directory" try { directoryScope = coreBackend.openDownloadDirectoryForRequest(request.requestJson) // Acquire the provider permit first. If several requests from @@ -1134,6 +1135,7 @@ class DownloadService : Service() { updateNativeWorkerItem(request.itemId) { it.status = "downloading" } + failureStage = "downloading" try { SafDownloadHandler.handle(this, request.requestJson, coreBackend) } finally { @@ -1150,6 +1152,7 @@ class DownloadService : Service() { var result = JSONObject(response) if (result.optBoolean("success", false)) { + failureStage = "finalizing" currentStatus = "finalizing" updateNativeWorkerItem(request.itemId) { it.status = "finalizing" @@ -1199,6 +1202,7 @@ class DownloadService : Service() { writeNativeAlbumReplayGainIfComplete() } } else { + result.put("failure_stage", failureStage) val errorType = result.optString("error_type") val errorMessage = result.optString("error") if (errorType == "cancelled" && @@ -1317,16 +1321,28 @@ class DownloadService : Service() { throw e } } catch (e: Exception) { + val errorMessage = "Native download failed during $failureStage: ${e.message ?: e.javaClass.simpleName}" + val failure = JSONObject() + .put("success", false) + .put("error", errorMessage) + .put("failure_stage", failureStage) + android.util.Log.w( + "DownloadService", + "Native item ${request.itemId} failed during $failureStage", + e, + ) updateNativeWorkerItem(request.itemId) { it.status = "failed" - it.error = e.message ?: "Native download failed" + it.error = errorMessage + it.resultJson = failure } writeNativeReplayGainJournal() writeNativeWorkerSnapshot( isRunning = true, isPaused = false, currentItemId = request.itemId, - message = e.message ?: "Native download failed", + message = errorMessage, + lastResult = failure, settingsJson = settingsJson, includeItems = true, ) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeMediaWorkspace.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeMediaWorkspace.kt new file mode 100644 index 00000000..35f2f050 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeMediaWorkspace.kt @@ -0,0 +1,25 @@ +package com.zarz.spotiflac + +import java.io.File +import java.io.IOException + +/** One workspace per process; in-flight media must survive cache eviction. */ +internal class NativeMediaWorkspace(privateDirectory: File) { + val directory = File(privateDirectory, "native-media-work") + + init { + // These app-owned scratch files cannot resume after process death. + // Reclaim them once at startup, before any download or scan can begin. + ensureDirectory().listFiles()?.forEach { it.delete() } + } + + fun ensureDirectory(): File { + if (!directory.mkdirs() && !directory.isDirectory) { + throw IOException("Could not prepare native media workspace: ${directory.path}") + } + return directory + } + + fun createTemporaryFile(prefix: String, suffix: String): File = + File.createTempFile(prefix, suffix, ensureDirectory()) +} diff --git a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt index eed99b12..9331de56 100644 --- a/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt +++ b/android/app/src/rust/kotlin/com/zarz/spotiflac/SelectedCoreBackend.kt @@ -46,7 +46,7 @@ internal suspend fun MainActivity.dispatchBackendApplication(call: MethodCall, r internal object RustCoreBackend : CoreBackend { override val implementation = "rust" override val routesApplication = true - private lateinit var root: File + private lateinit var workspace: NativeMediaWorkspace private var manager: ExtensionManager? = null private var repository: ExtensionRepository? = null private var requests: CancellationRegistry? = null @@ -62,7 +62,9 @@ internal object RustCoreBackend : CoreBackend { @Synchronized fun initialize(context: Context): RustCoreBackend { - if (!::root.isInitialized) root = File(context.applicationContext.cacheDir, "rust-core-pilot") + if (!::workspace.isInitialized) { + workspace = NativeMediaWorkspace(context.applicationContext.noBackupFilesDir) + } return this } @@ -103,7 +105,7 @@ internal object RustCoreBackend : CoreBackend { private fun setDownloadDirectory(path: String) { val current = owner() val storage = checkNotNull(identity) - val files = File(root, "files") + val files = workspace.ensureDirectory() val allowed = listOf(files.canonicalPath, files.absolutePath) + if (path.isEmpty()) emptyList() else directoryAliases(path, storage.first, storage.second) current.environment().use { it.setAllowedDownloadDirectories(allowed.distinct()) } @@ -144,8 +146,7 @@ internal object RustCoreBackend : CoreBackend { data, MessageDigest.getInstance("SHA-256").digest(key.toByteArray()).toList(), ) - val files = File(root, "files") - check(files.mkdirs() || files.isDirectory) + val files = workspace.ensureDirectory() val rawDirectories = arguments["allowed_directories"] require(rawDirectories == null || rawDirectories is List<*>) { "Invalid output directories" } val allowedDirectories = listOf(files.canonicalPath, files.absolutePath) + @@ -305,7 +306,7 @@ internal object RustCoreBackend : CoreBackend { // The support directory also contains extension storage. Keep the // Rust write inside its existing private staging root, then publish // the completed result through the native app's file access. - val staged = File.createTempFile("library_scan_", ".ndjson", File(root, "files")) + val staged = workspace.createTemporaryFile("library_scan_", ".ndjson") try { val count = current.scanLibraryFolderToNdjsonFile(File(folder).canonicalPath, staged.canonicalPath, null) check(staged.renameTo(File(output))) { "Failed to publish library scan output" } @@ -322,7 +323,7 @@ internal object RustCoreBackend : CoreBackend { withLibraryDirectories(listOf(folder)) { current -> if (snapshot.isEmpty()) current.scanLibraryFolderIncremental(File(folder).canonicalPath, "{}", null) else { - val staged = File.createTempFile("library_snapshot_", ".ndjson", File(root, "files")) + val staged = workspace.createTemporaryFile("library_snapshot_", ".ndjson") try { File(snapshot).copyTo(staged, overwrite = true) current.scanLibraryFolderIncrementalFromSnapshot(File(folder).canonicalPath, staged.canonicalPath, null) @@ -436,7 +437,7 @@ internal object RustCoreBackend : CoreBackend { suffix: String, ): File { owner() - return File.createTempFile(prefix, suffix, File(root, "files")) + return workspace.createTemporaryFile(prefix, suffix) } override fun openExtensionExecution(): CoreExtensionExecution { diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeMediaWorkspaceTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeMediaWorkspaceTest.kt new file mode 100644 index 00000000..7cc297c8 --- /dev/null +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeMediaWorkspaceTest.kt @@ -0,0 +1,77 @@ +package com.zarz.spotiflac + +import java.io.File +import java.io.IOException +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeMediaWorkspaceTest { + @Test + fun clearingCacheDoesNotRemoveInFlightMediaOrBreakTheNextAttempt() { + val app = Files.createTempDirectory("native-media-test").toFile() + try { + val cache = File(app, "cache").also { it.mkdirs() } + val legacy = File(cache, "rust-core-pilot/files").also { it.mkdirs() } + val workspace = NativeMediaWorkspace(File(app, "no_backup")) + val active = workspace.createTemporaryFile("native_saf_work_", ".m4a") + active.writeText("in-flight audio") + + assertTrue(cache.deleteRecursively()) + try { + File.createTempFile("native_saf_work_", ".m4a", legacy) + throw AssertionError("The old cache path should reproduce the missing-directory failure") + } catch (_: IOException) { + } + + assertEquals("in-flight audio", active.readText()) + assertTrue(workspace.createTemporaryFile("native_saf_work_", ".m4a").isFile) + } finally { + app.deleteRecursively() + } + } + + @Test + fun aNewProcessReclaimsInterruptedWorkWithoutTouchingSavedMusic() { + val app = Files.createTempDirectory("native-media-test").toFile() + try { + val privateDirectory = File(app, "no_backup") + val interrupted = NativeMediaWorkspace(privateDirectory) + .createTemporaryFile("native_saf_work_", ".m4a") + interrupted.writeText("interrupted transfer") + val savedMusic = File(app, "Music/song.m4a") + savedMusic.parentFile!!.mkdirs() + savedMusic.writeText("saved audio") + + val workspace = NativeMediaWorkspace(privateDirectory) + + assertFalse(interrupted.exists()) + assertEquals("saved audio", savedMusic.readText()) + assertTrue(workspace.createTemporaryFile("native_saf_work_", ".m4a").isFile) + } finally { + app.deleteRecursively() + } + } + + @Test + fun createsMissingParentsAndReportsAnUnusableWorkspace() { + val app = Files.createTempDirectory("native-media-test").toFile() + try { + val workspace = NativeMediaWorkspace(File(app, "no_backup")) + assertTrue(workspace.createTemporaryFile("library_scan_", ".ndjson").isFile) + + assertTrue(workspace.directory.deleteRecursively()) + workspace.directory.writeText("not a directory") + try { + workspace.createTemporaryFile("library_scan_", ".ndjson") + throw AssertionError("An unusable workspace must fail before creating a temporary file") + } catch (error: IOException) { + assertTrue(error.message.orEmpty().contains("Could not prepare native media workspace")) + } + } finally { + app.deleteRecursively() + } + } +} diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index 126cd866..97fca7be 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -1132,6 +1132,12 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { final errorMsg = (error == null || error.isEmpty) ? (resultMap?['error']?.toString() ?? 'Download failed') : error; + _log.e( + 'Native worker item $itemId failed ' + '(service: ${context.item.service}, ' + 'storage: ${context.storageMode}, ' + 'stage: ${resultMap?['failure_stage'] ?? 'unknown'}): $errorMsg', + ); final backendErrorType = resultMap == null ? null : downloadErrorTypeFromBackend(