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.
This commit is contained in:
austinried
2026-10-04 00:30:03 +00:00
parent 20814a10f8
commit ce035f457b
9 changed files with 222 additions and 5 deletions
+5
View File
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@@ -39,6 +40,10 @@
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
<receiver
android:name=".data.download.DownloadCancelReceiver"
android:exported="false" />
</application>
</manifest>
@@ -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()
}
}
}
}
@@ -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<SongDownload>): 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,
)
}
@@ -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
}
}
@@ -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)
@@ -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<SongDownload>) {
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())
}
@@ -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<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
single { PlaybackController(get(), get(), get(), get(), showMessage = toast) }
viewModel { RootViewModel(get()) }
+3
View File
@@ -1,4 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Subtracks</string>
<string name="download_cancel">Cancel</string>
<string name="download_channel">Downloads</string>
<string name="download_channel_description">Progress while songs download</string>
</resources>
@@ -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)
}