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 a2c76b4a..236f2c89 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -467,8 +467,58 @@ class DownloadService : Service() { */ override fun onTimeout(startId: Int, fgsType: Int) { android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.") - - stopForegroundService() + + stopNativeWorkerForSystemTimeout() + } + + private fun stopNativeWorkerForSystemTimeout() { + if (!hasNativeWorkerState()) { + stopForegroundService() + return + } + nativeWorkerCancelRequested = true + // Supersede the coroutine before cancelling it. Its catch/finally + // blocks must not publish a skipped/finished state over the recovery + // snapshot written below. + nativeWorkerGeneration++ + val activeItemIds = synchronized(nativeWorkerItems) { + nativeWorkerItems + .filter { NativeWorkerPolicy.statusAfterWorkerStop(it.status) != it.status } + .map { it.itemId } + } + for (itemId in activeItemIds) { + try { + Gobackend.cancelDownload(itemId) + } catch (_: Exception) { + } + } + NativeDownloadFinalizer.cancelActiveWork() + nativeWorkerJob?.cancel( + CancellationException("Native queue stopped by Android timeout") + ) + synchronized(nativeWorkerItems) { + for (item in nativeWorkerItems) { + val recoveredStatus = NativeWorkerPolicy.statusAfterWorkerStop(item.status) + if (recoveredStatus == item.status) continue + item.status = recoveredStatus + item.progress = 0.0 + item.bytesReceived = 0L + item.bytesTotal = 0L + item.error = "" + item.resultJson = null + } + } + nativeWorkerCurrentItemId = "" + writeNativeWorkerSnapshot( + isRunning = false, + isPaused = false, + currentItemId = "", + message = "Android background time limit reached", + includeItems = true, + ) + // Cancellation and the final recovery snapshot were handled above. + // Only tear down the foreground-service resources here. + stopForegroundService(cancelNativeWorker = false) } private fun createNotificationChannel() { @@ -981,7 +1031,9 @@ class DownloadService : Service() { ) } } catch (e: CancellationException) { - if (nativeWorkerCancelRequested) { + if (nativeWorkerCancelRequested && + generation == nativeWorkerGeneration + ) { updateNativeWorkerItem(request.itemId) { it.status = "skipped" it.error = "Cancelled" diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeWorkerPolicy.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeWorkerPolicy.kt index 2fb94932..ccedbb3f 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeWorkerPolicy.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeWorkerPolicy.kt @@ -68,4 +68,9 @@ internal object NativeWorkerPolicy { completed: Int, failed: Int, ): Boolean = !cancelRequested && completed + failed > 0 + + fun statusAfterWorkerStop(status: String): String = when (status) { + "preparing", "downloading", "finalizing" -> "queued" + else -> status + } } diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeWorkerPolicyTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeWorkerPolicyTest.kt index 776ed503..969e4ca4 100644 --- a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeWorkerPolicyTest.kt +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeWorkerPolicyTest.kt @@ -149,4 +149,15 @@ class NativeWorkerPolicyTest { ), ) } + + @Test + fun stoppedWorkerRequeuesOnlyInFlightItems() { + listOf("preparing", "downloading", "finalizing").forEach { status -> + assertEquals("queued", NativeWorkerPolicy.statusAfterWorkerStop(status)) + } + assertEquals("queued", NativeWorkerPolicy.statusAfterWorkerStop("queued")) + assertEquals("completed", NativeWorkerPolicy.statusAfterWorkerStop("completed")) + assertEquals("failed", NativeWorkerPolicy.statusAfterWorkerStop("failed")) + assertEquals("skipped", NativeWorkerPolicy.statusAfterWorkerStop("skipped")) + } } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index b073d960..186a1adb 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -75,6 +75,17 @@ bool canStartForegroundDownloadForLifecycle(AppLifecycleState? lifecycleState) { return lifecycleState == AppLifecycleState.resumed; } +String nativeWorkerStatusAfterSnapshotStop({ + required bool workerRunning, + required String status, +}) { + if (workerRunning) return status; + return switch (status) { + 'preparing' || 'downloading' || 'finalizing' => 'queued', + _ => status, + }; +} + /// Keeps a download in its finalizing state until its durable Library record /// has been written. If persistence fails, completion is deliberately not /// published so the queue can surface the error instead of losing the file diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index 4d307905..c871de51 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -863,6 +863,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { if (itemSnapshots.isEmpty) { return; } + final workerRunning = snapshot['is_running'] == true; for (final itemSnapshot in itemSnapshots) { final itemId = itemSnapshot['item_id']?.toString() ?? ''; @@ -872,7 +873,10 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { final context = contexts[itemId]; if (context == null) continue; - final status = itemSnapshot['status']?.toString() ?? 'queued'; + final status = nativeWorkerStatusAfterSnapshotStop( + workerRunning: workerRunning, + status: itemSnapshot['status']?.toString() ?? 'queued', + ); final progress = ((itemSnapshot['progress'] as num?)?.toDouble() ?? 0.0) .clamp(0.0, 1.0) .toDouble(); diff --git a/test/download_start_policy_test.dart b/test/download_start_policy_test.dart index ba72dc82..0f421188 100644 --- a/test/download_start_policy_test.dart +++ b/test/download_start_policy_test.dart @@ -51,4 +51,42 @@ void main() { ); }); }); + + group('native worker stop recovery', () { + test('requeues every in-flight snapshot state after the worker stops', () { + for (final status in const ['preparing', 'downloading', 'finalizing']) { + expect( + nativeWorkerStatusAfterSnapshotStop( + workerRunning: false, + status: status, + ), + 'queued', + ); + } + }); + + test('preserves terminal states and live worker progress', () { + expect( + nativeWorkerStatusAfterSnapshotStop( + workerRunning: false, + status: 'completed', + ), + 'completed', + ); + expect( + nativeWorkerStatusAfterSnapshotStop( + workerRunning: false, + status: 'failed', + ), + 'failed', + ); + expect( + nativeWorkerStatusAfterSnapshotStop( + workerRunning: true, + status: 'downloading', + ), + 'downloading', + ); + }); + }); }