perf(downloads): bound native queue state

This commit is contained in:
zarzet
2026-08-27 00:17:25 +07:00
parent 5e8608cef9
commit 04c42bd511
10 changed files with 483 additions and 108 deletions
@@ -64,6 +64,8 @@ class DownloadService : Service() {
const val ACTION_APPEND_NATIVE_QUEUE = "com.zarz.spotiflac.action.APPEND_NATIVE_QUEUE"
const val ACTION_FINISH_NATIVE_QUEUE_PREPARATION =
"com.zarz.spotiflac.action.FINISH_NATIVE_QUEUE_PREPARATION"
const val ACTION_ACKNOWLEDGE_NATIVE_QUEUE_ITEMS =
"com.zarz.spotiflac.action.ACKNOWLEDGE_NATIVE_QUEUE_ITEMS"
const val ACTION_PAUSE_NATIVE_QUEUE = "com.zarz.spotiflac.action.PAUSE_NATIVE_QUEUE"
const val ACTION_RESUME_NATIVE_QUEUE = "com.zarz.spotiflac.action.RESUME_NATIVE_QUEUE"
const val ACTION_CANCEL_NATIVE_QUEUE = "com.zarz.spotiflac.action.CANCEL_NATIVE_QUEUE"
@@ -79,6 +81,7 @@ class DownloadService : Service() {
const val EXTRA_REQUESTS_PATH = "requests_path"
const val EXTRA_SETTINGS_PATH = "settings_path"
const val EXTRA_RUN_ID = "run_id"
const val EXTRA_ITEM_IDS_JSON = "item_ids_json"
internal const val NATIVE_WORKER_STATE_FILE = "native_download_worker_state.json"
internal const val NATIVE_WORKER_PROGRESS_FILE = "native_download_worker_progress.json"
internal const val NATIVE_REPLAYGAIN_JOURNAL_FILE = "native_replaygain_journal.json"
@@ -168,6 +171,19 @@ class DownloadService : Service() {
context.startService(intent)
}
fun acknowledgeNativeQueueItems(
context: Context,
runId: String,
itemIdsJson: String,
) {
val intent = Intent(context, DownloadService::class.java).apply {
action = ACTION_ACKNOWLEDGE_NATIVE_QUEUE_ITEMS
putExtra(EXTRA_RUN_ID, runId)
putExtra(EXTRA_ITEM_IDS_JSON, itemIdsJson)
}
context.startService(intent)
}
fun pauseNativeQueue(context: Context) {
val intent = Intent(context, DownloadService::class.java).apply {
action = ACTION_PAUSE_NATIVE_QUEUE
@@ -294,7 +310,6 @@ class DownloadService : Service() {
val itemId: String,
val trackName: String,
val artistName: String,
val itemJson: String = "",
var status: String = "queued",
var progress: Double = 0.0,
var bytesReceived: Long = 0L,
@@ -312,13 +327,14 @@ class DownloadService : Service() {
internal val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
internal var nativeWorkerJob: Job? = null
@Volatile internal var pendingNativeItemsSnapshotJob: Job? = null
private var nativeWorkerRequestChannel: Channel<NativeDownloadRequest>? = null
@Volatile private var nativeWorkerPreparationComplete = true
private var wakeLock: PowerManager.WakeLock? = null
private var currentTrackName = ""
private var currentArtistName = ""
internal var currentStatus = "preparing"
private var queueCount = 0
internal var queueCount = 0
// Signature of the last home-screen widget push; keeps widget updates
// event-driven (track/status/queue changes, 25% steps), never per byte.
private var widgetSignature = ""
@@ -327,6 +343,7 @@ class DownloadService : Service() {
internal var nativeWorkerRunId = ""
@Volatile private var nativeWorkerCurrentItemId = ""
internal val nativeWorkerItems = mutableListOf<NativeWorkerItem>()
internal val nativeWorkerTerminalStatuses = mutableMapOf<String, String>()
internal val nativeReplayGainEntries = mutableListOf<JSONObject>()
internal val nativeReplayGainRequestAlbumKeys = mutableMapOf<String, String>()
internal val snapshotWriteLock = Any()
@@ -411,6 +428,12 @@ class DownloadService : Service() {
intent.getStringExtra(EXTRA_RUN_ID).orEmpty(),
)
}
ACTION_ACKNOWLEDGE_NATIVE_QUEUE_ITEMS -> {
acknowledgeNativeWorkerItems(
intent.getStringExtra(EXTRA_RUN_ID).orEmpty(),
intent.getStringExtra(EXTRA_ITEM_IDS_JSON).orEmpty(),
)
}
ACTION_PAUSE_NATIVE_QUEUE -> {
nativeWorkerPaused = true
cancelActiveNativeItemForPause()
@@ -438,6 +461,7 @@ class DownloadService : Service() {
nativeWorkerVerificationPaused = false
nativeWorkerPreparationComplete = true
nativeWorkerRequestChannel?.close()
cancelScheduledNativeWorkerItemsSnapshot()
cancelNativeVerificationNotification()
synchronized(nativeWorkerItems) {
for (item in nativeWorkerItems) {
@@ -529,6 +553,8 @@ class DownloadService : Service() {
nativeWorkerCancelRequested = true
nativeWorkerPreparationComplete = true
nativeWorkerRequestChannel?.close()
pendingNativeItemsSnapshotJob?.cancel()
pendingNativeItemsSnapshotJob = null
// Supersede the coroutine before cancelling it. Its catch/finally
// blocks must not publish a skipped/finished state over the recovery
// snapshot written below.
@@ -655,6 +681,8 @@ class DownloadService : Service() {
}
NativeDownloadFinalizer.cancelActiveWork()
nativeWorkerRequestChannel?.close()
pendingNativeItemsSnapshotJob?.cancel()
pendingNativeItemsSnapshotJob = null
nativeWorkerGeneration++
val generation = nativeWorkerGeneration
nativeWorkerJob?.cancel(CancellationException("Native queue replaced"))
@@ -696,13 +724,13 @@ class DownloadService : Service() {
}
synchronized(nativeWorkerItems) {
nativeWorkerItems.clear()
nativeWorkerTerminalStatuses.clear()
nativeWorkerItems.addAll(
requests.map {
NativeWorkerItem(
itemId = it.itemId,
trackName = it.trackName,
artistName = it.artistName,
itemJson = it.itemJson
)
}
)
@@ -762,7 +790,9 @@ class DownloadService : Service() {
if (requests.isEmpty()) return
val knownIds = synchronized(nativeWorkerItems) {
nativeWorkerItems.mapTo(mutableSetOf()) { it.itemId }
nativeWorkerItems.mapTo(mutableSetOf()) { it.itemId }.apply {
addAll(nativeWorkerTerminalStatuses.keys)
}
}
val additions = requests.filter { knownIds.add(it.itemId) }
if (additions.isEmpty()) return
@@ -788,7 +818,6 @@ class DownloadService : Service() {
itemId = it.itemId,
trackName = it.trackName,
artistName = it.artistName,
itemJson = it.itemJson,
)
},
)
@@ -798,12 +827,11 @@ class DownloadService : Service() {
channel.trySend(request)
}
writeNativeReplayGainJournal()
writeNativeWorkerSnapshotAsync(
scheduleNativeWorkerItemsSnapshot(
isRunning = nativeWorkerJob?.isActive == true,
isPaused = isNativeWorkerPaused(),
currentItemId = nativeWorkerCurrentItemId,
message = "Preparing queue",
includeItems = true,
)
}
@@ -811,9 +839,54 @@ class DownloadService : Service() {
if (runId.isBlank() || runId != nativeWorkerRunId) return
nativeWorkerPreparationComplete = true
nativeWorkerRequestChannel?.close()
flushScheduledNativeWorkerItemsSnapshot(
isRunning = nativeWorkerJob?.isActive == true,
isPaused = isNativeWorkerPaused(),
currentItemId = nativeWorkerCurrentItemId,
message = "Queue prepared",
)
writeNativeAlbumReplayGainIfComplete()
}
private fun acknowledgeNativeWorkerItems(runId: String, itemIdsJson: String) {
if (runId.isBlank() || runId != nativeWorkerRunId || itemIdsJson.isBlank()) return
val itemIds = try {
val array = JSONArray(itemIdsJson)
mutableSetOf<String>().apply {
for (index in 0 until array.length()) {
val itemId = array.optString(index, "").trim()
if (itemId.isNotEmpty()) {
add(itemId)
}
}
}
} catch (_: Exception) {
return
}
if (itemIds.isEmpty()) return
var releasedAny = false
synchronized(nativeWorkerItems) {
val iterator = nativeWorkerItems.iterator()
while (iterator.hasNext()) {
val item = iterator.next()
if (item.itemId !in itemIds || !NativeWorkerPolicy.isTerminalStatus(item.status)) {
continue
}
nativeWorkerTerminalStatuses[item.itemId] = item.status
iterator.remove()
releasedAny = true
}
}
if (!releasedAny) return
scheduleNativeWorkerItemsSnapshot(
isRunning = nativeWorkerJob?.isActive == true,
isPaused = isNativeWorkerPaused(),
currentItemId = nativeWorkerCurrentItemId,
message = "Queue updated",
)
}
internal fun isNativeWorkerPaused(): Boolean =
nativeWorkerPaused ||
nativeWorkerNetworkPaused ||
@@ -1278,39 +1351,41 @@ class DownloadService : Service() {
try {
supervisorScope {
val itemJobs = mutableListOf<Job>()
for (request in requests) {
if (nativeWorkerCancelRequested ||
generation != nativeWorkerGeneration
) {
break
}
itemJobs += launch {
val providerKey = nativeRequestProviderKey(request)
val providerLimit = minOf(
concurrency,
nativeRequestProviderConcurrency(request),
)
val providerSemaphore = providerSemaphores.computeIfAbsent(
providerKey,
) {
Semaphore(providerLimit)
val workers = List(concurrency) {
launch {
for (request in requests) {
if (nativeWorkerCancelRequested ||
generation != nativeWorkerGeneration
) {
break
}
val providerKey = nativeRequestProviderKey(request)
val providerLimit = minOf(
concurrency,
nativeRequestProviderConcurrency(request),
)
val providerSemaphore = providerSemaphores.computeIfAbsent(
providerKey,
) {
Semaphore(providerLimit)
}
processConcurrentNativeRequest(
request = request,
settingsJson = settingsJson,
generation = generation,
networkSemaphore = networkSemaphore,
providerSemaphore = providerSemaphore,
finalizerMutex = finalizerMutex,
rateLimitAttempts = rateLimitAttempts,
)
}
processConcurrentNativeRequest(
request = request,
settingsJson = settingsJson,
generation = generation,
networkSemaphore = networkSemaphore,
providerSemaphore = providerSemaphore,
finalizerMutex = finalizerMutex,
rateLimitAttempts = rateLimitAttempts,
)
}
}
itemJobs.joinAll()
workers.joinAll()
}
} finally {
if (generation == nativeWorkerGeneration) {
cancelScheduledNativeWorkerItemsSnapshot()
nativeWorkerRequestChannel = null
nativeWorkerPreparationComplete = true
if (!nativeWorkerCancelRequested) {
@@ -1647,6 +1722,7 @@ class DownloadService : Service() {
}
} finally {
if (generation == nativeWorkerGeneration) {
cancelScheduledNativeWorkerItemsSnapshot()
if (!nativeWorkerCancelRequested) {
flushNativeAlbumReplayGainJournalIfComplete()
}
@@ -1709,6 +1785,7 @@ class DownloadService : Service() {
@Synchronized
private fun stopForegroundService(cancelNativeWorker: Boolean = true) {
cancelScheduledNativeWorkerItemsSnapshot()
if (cancelNativeWorker) {
nativeWorkerCancelRequested = true
nativeWorkerPreparationComplete = true
@@ -1939,6 +2016,7 @@ class DownloadService : Service() {
}
override fun onDestroy() {
cancelScheduledNativeWorkerItemsSnapshot()
unregisterNativeWorkerNetworkCallback()
nativeWorkerCancelRequested = true
nativeWorkerPreparationComplete = true
@@ -39,9 +39,7 @@ internal fun DownloadService.writeNativeAlbumReplayGainIfComplete(): Boolean {
}
if (entries.size <= 1) return true
val statuses = synchronized(nativeWorkerItems) {
nativeWorkerItems.associate { it.itemId to it.status }
}
val statuses = nativeWorkerStatusesSnapshot()
val requestKeys = synchronized(nativeReplayGainRequestAlbumKeys) {
nativeReplayGainRequestAlbumKeys.toMap()
}
@@ -93,9 +91,7 @@ internal fun DownloadService.writeNativeReplayGainJournal() {
val entries = synchronized(nativeReplayGainEntries) {
nativeReplayGainEntries.map { JSONObject(it.toString()) }
}
val statuses = synchronized(nativeWorkerItems) {
nativeWorkerItems.associate { it.itemId to it.status }
}
val statuses = nativeWorkerStatusesSnapshot()
synchronized(DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE_LOCK) {
val file = AtomicFile(File(filesDir, DownloadService.NATIVE_REPLAYGAIN_JOURNAL_FILE))
val existing = readNativeReplayGainJournalLocked(file)
@@ -134,6 +130,16 @@ internal fun DownloadService.writeNativeReplayGainJournal() {
}
}
internal fun DownloadService.nativeWorkerStatusesSnapshot(): Map<String, String> {
return synchronized(nativeWorkerItems) {
val statuses = nativeWorkerTerminalStatuses.toMutableMap()
for (item in nativeWorkerItems) {
statuses[item.itemId] = item.status
}
statuses
}
}
internal fun DownloadService.readNativeReplayGainJournalLocked(file: AtomicFile): JSONObject? {
return try {
if (!file.baseFile.exists()) return null
@@ -144,6 +144,47 @@ internal fun DownloadService.writeNativeWorkerSnapshotAsync(
}
}
internal fun DownloadService.scheduleNativeWorkerItemsSnapshot(
isRunning: Boolean,
isPaused: Boolean,
currentItemId: String,
message: String,
) {
pendingNativeItemsSnapshotJob?.cancel()
pendingNativeItemsSnapshotJob = serviceScope.launch {
delay(250)
writeNativeWorkerSnapshot(
isRunning = isRunning,
isPaused = isPaused,
currentItemId = currentItemId,
message = message,
includeItems = true,
)
}
}
internal fun DownloadService.flushScheduledNativeWorkerItemsSnapshot(
isRunning: Boolean,
isPaused: Boolean,
currentItemId: String,
message: String,
) {
pendingNativeItemsSnapshotJob?.cancel()
pendingNativeItemsSnapshotJob = null
writeNativeWorkerSnapshotAsync(
isRunning = isRunning,
isPaused = isPaused,
currentItemId = currentItemId,
message = message,
includeItems = true,
)
}
internal fun DownloadService.cancelScheduledNativeWorkerItemsSnapshot() {
pendingNativeItemsSnapshotJob?.cancel()
pendingNativeItemsSnapshotJob = null
}
internal fun DownloadService.readNativeWorkerRunIdFromSnapshotFile(): String {
return try {
synchronized(DownloadService.NATIVE_WORKER_STATE_FILE_LOCK) {
@@ -231,7 +272,14 @@ internal fun DownloadService.nativeWorkerCounts(): DownloadService.NativeWorkerC
var failed = 0
var skipped = 0
synchronized(nativeWorkerItems) {
total = nativeWorkerItems.size
total = nativeWorkerTerminalStatuses.size + nativeWorkerItems.size
for (status in nativeWorkerTerminalStatuses.values) {
when (status) {
"completed" -> completed++
"failed" -> failed++
"skipped" -> skipped++
}
}
for (item in nativeWorkerItems) {
when (item.status) {
"completed" -> completed++
@@ -241,7 +289,7 @@ internal fun DownloadService.nativeWorkerCounts(): DownloadService.NativeWorkerC
}
}
return DownloadService.NativeWorkerCounts(
total = total,
total = maxOf(queueCount, total),
completed = completed,
failed = failed,
skipped = skipped
@@ -286,7 +334,6 @@ internal fun DownloadService.nativeWorkerItemSnapshotLocked(item: DownloadServic
if (includeStatic) {
json.put("track_name", item.trackName)
.put("artist_name", item.artistName)
.put("item_json", item.itemJson)
}
if (item.error.isNotBlank()) {
json.put("error", item.error)
@@ -294,4 +341,3 @@ internal fun DownloadService.nativeWorkerItemSnapshotLocked(item: DownloadServic
item.resultJson?.let { json.put("result", it) }
return json
}
@@ -1777,6 +1777,18 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"acknowledgeNativeDownloadWorkerItems" -> {
val runId = call.argument<String>("run_id") ?: ""
val itemIdsJson = call.argument<String>("item_ids_json") ?: "[]"
if (runId.isNotBlank()) {
DownloadService.acknowledgeNativeQueueItems(
this@MainActivity,
runId,
itemIdsJson,
)
}
result.success(null)
}
"pauseNativeDownloadWorker" -> {
DownloadService.pauseNativeQueue(this@MainActivity)
result.success(null)
@@ -69,6 +69,9 @@ internal object NativeWorkerPolicy {
failed: Int,
): Boolean = !cancelRequested && completed + failed > 0
fun isTerminalStatus(status: String): Boolean =
status == "completed" || status == "failed" || status == "skipped"
fun statusAfterWorkerStop(status: String): String = when (status) {
"preparing", "downloading", "finalizing" -> "queued"
else -> status
@@ -160,4 +160,14 @@ class NativeWorkerPolicyTest {
assertEquals("failed", NativeWorkerPolicy.statusAfterWorkerStop("failed"))
assertEquals("skipped", NativeWorkerPolicy.statusAfterWorkerStop("skipped"))
}
@Test
fun acknowledgedPayloadsAreReleasedOnlyForTerminalItems() {
listOf("completed", "failed", "skipped").forEach { status ->
assertTrue(NativeWorkerPolicy.isTerminalStatus(status))
}
listOf("queued", "preparing", "downloading", "finalizing").forEach { status ->
assertFalse(NativeWorkerPolicy.isTerminalStatus(status))
}
}
}