From ce035f457b6c0cac972ab58d3c83c1fd3e5152d9 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:44:18 +0000 Subject: [PATCH] post our own download progress notification The platform posts one notification per download, so queueing an album buries the UI in them and they can be swiped away. Hiding them (VISIBILITY_HIDDEN, which needs DOWNLOAD_WITHOUT_NOTIFICATION) and posting one aggregated notification instead gives a single ongoing entry with the overall progress and a Cancel action that stops everything in flight. It is refreshed from the download state the poller already maintains, so it tracks the engine without extra queries, and it is tied to this process: if we are killed the downloads continue with no notification, which is the trade for not writing a service. --- app/src/main/AndroidManifest.xml | 5 + .../data/download/DownloadCancelReceiver.kt | 32 +++++++ .../data/download/DownloadNotification.kt | 22 +++++ .../data/download/DownloadNotifier.kt | 91 +++++++++++++++++++ .../data/download/SystemDownloadEngine.kt | 2 +- .../subtracks/data/repo/DownloadRepository.kt | 16 +++- .../main/kotlin/com/subtracks/di/AppModule.kt | 2 + app/src/main/res/values/strings.xml | 3 + .../data/download/DownloadNotificationTest.kt | 54 +++++++++++ 9 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 app/src/main/kotlin/com/subtracks/data/download/DownloadCancelReceiver.kt create mode 100644 app/src/main/kotlin/com/subtracks/data/download/DownloadNotification.kt create mode 100644 app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt create mode 100644 app/src/test/kotlin/com/subtracks/data/download/DownloadNotificationTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c421969f..013b7533 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + @@ -39,6 +40,10 @@ + + diff --git a/app/src/main/kotlin/com/subtracks/data/download/DownloadCancelReceiver.kt b/app/src/main/kotlin/com/subtracks/data/download/DownloadCancelReceiver.kt new file mode 100644 index 00000000..55370ffd --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/download/DownloadCancelReceiver.kt @@ -0,0 +1,32 @@ +package com.subtracks.data.download + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.subtracks.data.repo.DownloadRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class DownloadCancelReceiver : + BroadcastReceiver(), + KoinComponent { + private val repository: DownloadRepository by inject() + + override fun onReceive( + context: Context, + intent: Intent, + ) { + val pending = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + repository.cancelActive() + } finally { + pending.finish() + } + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/data/download/DownloadNotification.kt b/app/src/main/kotlin/com/subtracks/data/download/DownloadNotification.kt new file mode 100644 index 00000000..e65aa639 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/download/DownloadNotification.kt @@ -0,0 +1,22 @@ +package com.subtracks.data.download + +import com.subtracks.data.model.DownloadStatus +import com.subtracks.data.model.SongDownload + +data class DownloadNotification( + val title: String, + val text: String, + val progress: Float?, +) + +fun downloadNotification(rows: Collection): DownloadNotification? { + val active = rows.filter { it.status == DownloadStatus.Queued || it.status == DownloadStatus.Running } + if (active.isEmpty()) return null + val total = active.sumOf { it.total } + val progress = if (total > 0) (active.sumOf { it.bytes }.toFloat() / total).coerceIn(0f, 1f) else null + return DownloadNotification( + title = if (active.size == 1) "Downloading 1 song" else "Downloading ${active.size} songs", + text = progress?.let { "${(it * 100).toInt()}%" } ?: "In progress", + progress = progress, + ) +} diff --git a/app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt b/app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt new file mode 100644 index 00000000..d6f1a131 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/download/DownloadNotifier.kt @@ -0,0 +1,91 @@ +package com.subtracks.data.download + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.subtracks.R +import com.subtracks.data.repo.DownloadRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Our own aggregated progress notification for the downloads the platform engine runs. The engine's + * per-download notifications are hidden (VISIBILITY_HIDDEN), so this is the only sign of progress, + * and it only exists while this process does. + */ +class DownloadNotifier( + private val context: Context, + private val repository: DownloadRepository, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val manager = NotificationManagerCompat.from(context) + + fun start() { + createChannel() + scope.launch { + repository.states().collect { rows -> + val content = downloadNotification(rows.values) + if (content == null) { + manager.cancel(ID) + } else if (manager.areNotificationsEnabled()) { + manager.notify(ID, build(content)) + } + } + } + } + + private fun build(content: DownloadNotification): Notification = + NotificationCompat + .Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_name) + .setContentTitle(content.title) + .setContentText(content.text) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setProgress(PROGRESS_MAX, ((content.progress ?: 0f) * PROGRESS_MAX).toInt(), content.progress == null) + .addAction(0, context.getString(R.string.download_cancel), cancelIntent()) + .setContentIntent(launchIntent()) + .build() + + private fun cancelIntent(): PendingIntent = + PendingIntent.getBroadcast( + context, + 0, + Intent(context, DownloadCancelReceiver::class.java).setAction(ACTION_CANCEL), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + private fun launchIntent(): PendingIntent? = + context.packageManager + .getLaunchIntentForPackage(context.packageName) + ?.let { PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE) } + + private fun createChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService(NotificationManager::class.java) ?: return + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, context.getString(R.string.download_channel), NotificationManager.IMPORTANCE_LOW).apply { + description = context.getString(R.string.download_channel_description) + setShowBadge(false) + }, + ) + } + + companion object { + private const val ID = 1 + private const val CHANNEL_ID = "downloads" + private const val ACTION_CANCEL = "com.subtracks.CANCEL_DOWNLOADS" + private const val PROGRESS_MAX = 100 + } +} diff --git a/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt b/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt index 4392d544..3fe2b963 100644 --- a/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt +++ b/app/src/main/kotlin/com/subtracks/data/download/SystemDownloadEngine.kt @@ -14,7 +14,7 @@ class SystemDownloadEngine( .Request(Uri.parse(request.uri)) .setTitle(request.title) .setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN) // Downloads wait for an unmetered network until the preference for them exists. .setAllowedOverMetered(false) return manager().enqueue(download) diff --git a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt index be7132ae..c67212a6 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt @@ -293,12 +293,20 @@ class DownloadRepository( list: DownloadList, refId: String, selecting: (SongDownload) -> Boolean, - ) { - val rows = rowsFor(sourceId, list, refId).filter(selecting) + ) = removeRows(rowsFor(sourceId, list, refId).filter(selecting)) + + suspend fun cancelActive() = + withContext(dispatcher) { + mutex.withLock { removeRows(db.downloadDao().all().filter { it.status.isActive }) } + } + + private suspend fun removeRows(rows: List) { if (rows.isEmpty()) return engine.cancel(rows.mapNotNull { it.engineId }) - rows.forEach { file(sourceId, it.songId).delete() } - rows.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) } + rows.groupBy { it.sourceId }.forEach { (sourceId, group) -> + group.forEach { file(sourceId, it.songId).delete() } + group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) } + } sweep(db.downloadDao().all()) } diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index d7a64ebf..338331f4 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -9,6 +9,7 @@ import com.subtracks.data.db.createAndroidDatabase import com.subtracks.data.download.ArtworkFetcher import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.DownloadEngine +import com.subtracks.data.download.DownloadNotifier import com.subtracks.data.download.OkHttpArtworkFetcher import com.subtracks.data.download.SystemDownloadEngine import com.subtracks.data.net.networkMode @@ -67,6 +68,7 @@ fun appModule( showMessage = toast, ).also { it.start() } } + single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get()).also { it.start() } } single { MediaSessionConnection(context.applicationContext, get(), get()) } single { PlaybackController(get(), get(), get(), get(), showMessage = toast) } viewModel { RootViewModel(get()) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 98d44127..130ac239 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,7 @@ Subtracks + Cancel + Downloads + Progress while songs download diff --git a/app/src/test/kotlin/com/subtracks/data/download/DownloadNotificationTest.kt b/app/src/test/kotlin/com/subtracks/data/download/DownloadNotificationTest.kt new file mode 100644 index 00000000..fd48fe82 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/download/DownloadNotificationTest.kt @@ -0,0 +1,54 @@ +package com.subtracks.data.download + +import com.subtracks.data.model.DownloadStatus +import com.subtracks.data.model.SongDownload +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DownloadNotificationTest { + @Test + fun nothingInFlightMeansNoNotification() { + assertNull(downloadNotification(emptyList())) + assertNull(downloadNotification(listOf(row("s1", DownloadStatus.Completed), row("s2", DownloadStatus.Failed)))) + } + + @Test + fun oneRunningDownloadReportsItsShare() { + val notification = downloadNotification(listOf(row("s1", DownloadStatus.Running, bytes = 50, total = 100))) + + assertEquals("Downloading 1 song", notification?.title) + assertEquals("50%", notification?.text) + assertEquals(0.5f, notification?.progress) + } + + @Test + fun theProgressIsTheShareOfEveryActiveDownload() { + val notification = + downloadNotification( + listOf( + row("s1", DownloadStatus.Running, bytes = 50, total = 100), + row("s2", DownloadStatus.Queued, bytes = 150, total = 300), + row("s3", DownloadStatus.Completed, bytes = 10, total = 10), + ), + ) + + assertEquals("Downloading 2 songs", notification?.title) + assertEquals(0.5f, notification?.progress) + } + + @Test + fun anUnknownTotalLeavesTheProgressIndeterminate() { + val notification = downloadNotification(listOf(row("s1", DownloadStatus.Running))) + + assertEquals("In progress", notification?.text) + assertNull(notification?.progress) + } + + private fun row( + songId: String, + status: DownloadStatus, + bytes: Long = 0, + total: Long = 0, + ) = SongDownload(sourceId = 1, songId = songId, status = status, bytes = bytes, total = total) +}