mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
fix(download): preserve native media workspace across cache eviction
This commit is contained in:
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(
|
||||
|
||||
Reference in new issue
Block a user