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)
+}