fix(download): preserve native media workspace across cache eviction

This commit is contained in:
zarzet committed 2026-10-02 11:44:29 +07:00
1 parent 19cb703379
commit 8758790d9f
5 files changed
+135 -10

No files matched your search

@@ -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,
)
@@ -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())
}
@@ -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 {
@@ -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()
}
}
}
@@ -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(