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