mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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()) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user