Group download notification

This commit is contained in:
Koitharu
2022-08-11 11:40:27 +03:00
parent d5bea0ca53
commit 43ef130052
9 changed files with 581 additions and 555 deletions

View File

@@ -111,6 +111,7 @@
<service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:stopWithTask="false"
android:foregroundServiceType="dataSync" />
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
<service

View File

@@ -43,10 +43,10 @@ class DownloadManager(
) {
private val coverWidth = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_width
androidx.core.R.dimen.compat_notification_large_icon_max_width,
)
private val coverHeight = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_height
androidx.core.R.dimen.compat_notification_large_icon_max_height,
)
private val semaphore = Semaphore(settings.downloadsParallelism)
@@ -56,7 +56,7 @@ class DownloadManager(
startId: Int,
): PausingProgressJob<DownloadState> {
val stateFlow = MutableStateFlow<DownloadState>(
DownloadState.Queued(startId = startId, manga = manga, cover = null)
DownloadState.Queued(startId = startId, manga = manga, cover = null),
)
val pausingHandle = PausingHandle()
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId)
@@ -100,7 +100,7 @@ class DownloadManager(
data.chapters
} else {
data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
}
},
) { "Chapters list must not be null" }
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
check(chaptersIdsSet.isNullOrEmpty()) {
@@ -118,7 +118,7 @@ class DownloadManager(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url)
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
}
outState.value = DownloadState.Progress(
@@ -128,7 +128,7 @@ class DownloadManager(
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex
currentPage = pageIndex,
)
if (settings.isDownloadsSlowdownEnabled) {
@@ -209,7 +209,7 @@ class DownloadManager(
manga = prevValue.manga,
cover = prevValue.cover,
error = throwable,
canRetry = false
canRetry = false,
)
}
@@ -220,7 +220,7 @@ class DownloadManager(
.referer(manga.publicUrl)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build()
.build(),
).drawable
}.getOrNull()
@@ -240,7 +240,7 @@ class DownloadManager(
okHttp = okHttp,
cache = cache,
localMangaRepository = localMangaRepository,
settings = settings
settings = settings,
)
}
}

View File

@@ -108,34 +108,6 @@ sealed interface DownloadState {
}
}
@Deprecated("TODO: remove")
class WaitingForNetwork(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as WaitingForNetwork
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
class Done(
override val startId: Int,
override val manga: Manga,

View File

@@ -18,9 +18,8 @@ fun downloadItemAD(
scope: CoroutineScope,
coil: ImageLoader,
) = adapterDelegateViewBinding<ProgressJob<DownloadState>, ProgressJob<DownloadState>, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) },
) {
var job: Job? = null
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
@@ -91,13 +90,6 @@ fun downloadItemAD(
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
is DownloadState.WaitingForNetwork -> {
binding.textViewStatus.setText(R.string.waiting_for_network)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
}
}
}.launchIn(scope)
}

View File

@@ -7,21 +7,126 @@ import android.app.PendingIntent
import android.content.Context
import android.os.Build
import android.text.format.DateUtils
import android.util.SparseArray
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import com.google.android.material.R as materialR
import androidx.core.text.HtmlCompat
import androidx.core.text.htmlEncode
import androidx.core.text.parseAsHtml
import androidx.core.util.forEach
import androidx.core.util.size
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import com.google.android.material.R as materialR
class DownloadNotification(private val context: Context, startId: Int) {
class DownloadNotification(private val context: Context) {
private val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val states = SparseArray<DownloadState>()
private val groupBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
private val listIntent = PendingIntent.getActivity(
context,
REQUEST_LIST,
DownloadsActivity.newIntent(context),
PendingIntentCompat.FLAG_IMMUTABLE,
)
init {
groupBuilder.setOnlyAlertOnce(true)
groupBuilder.setDefaults(0)
groupBuilder.color = ContextCompat.getColor(context, R.color.blue_primary)
groupBuilder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
groupBuilder.setSilent(true)
groupBuilder.setGroup(GROUP_ID)
groupBuilder.setContentIntent(listIntent)
groupBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
groupBuilder.setGroupSummary(true)
groupBuilder.setContentTitle(context.getString(R.string.downloading_manga))
}
fun buildGroupNotification(): Notification {
val style = NotificationCompat.InboxStyle(groupBuilder)
var progress = 0f
var isAllDone = true
groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
states.forEach { _, state ->
if (state.manga.isNsfw) {
groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
}
val summary = when (state) {
is DownloadState.Cancelled -> {
progress++
context.getString(R.string.cancelling_)
}
is DownloadState.Done -> {
progress++
context.getString(R.string.completed)
}
is DownloadState.Error -> {
isAllDone = false
context.getString(R.string.error)
}
is DownloadState.PostProcessing -> {
progress++
isAllDone = false
context.getString(R.string.processing_)
}
is DownloadState.Preparing -> {
isAllDone = false
context.getString(R.string.preparing_)
}
is DownloadState.Progress -> {
isAllDone = false
progress += state.percent
context.getString(R.string.percent_string_pattern, (state.percent * 100).format())
}
is DownloadState.Queued -> {
isAllDone = false
context.getString(R.string.queued)
}
}
style.addLine(
context.getString(
R.string.download_summary_pattern,
state.manga.title.ellipsize(10).htmlEncode(),
summary.htmlEncode(),
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY),
)
}
progress /= states.size.toFloat()
style.setBigContentTitle(context.getString(R.string.downloading_manga))
groupBuilder.setContentText(context.resources.getQuantityString(R.plurals.items, states.size, states.size()))
groupBuilder.setNumber(states.size)
groupBuilder.setSmallIcon(
if (isAllDone) android.R.drawable.stat_sys_download_done else android.R.drawable.stat_sys_download,
)
when (progress) {
1f -> groupBuilder.setProgress(0, 0, false)
0f -> groupBuilder.setProgress(1, 0, true)
else -> groupBuilder.setProgress(100, (progress * 100f).toInt(), progress == 0f)
}
return groupBuilder.build()
}
fun dismiss() {
manager.cancel(ID_GROUP)
}
fun newItem(startId: Int) = Item(startId)
inner class Item(
private val startId: Int,
) {
private val builder = NotificationCompat.Builder(context, CHANNEL_ID)
private val cancelAction = NotificationCompat.Action(
@@ -31,8 +136,8 @@ class DownloadNotification(private val context: Context, startId: Int) {
context,
startId * 2,
DownloadService.getCancelIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
),
)
private val retryAction = NotificationCompat.Action(
R.drawable.ic_restart_black,
@@ -41,14 +146,8 @@ class DownloadNotification(private val context: Context, startId: Int) {
context,
startId * 2 + 1,
DownloadService.getResumeIntent(startId),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
private val listIntent = PendingIntent.getActivity(
context,
REQUEST_LIST,
DownloadsActivity.newIntent(context),
PendingIntentCompat.FLAG_IMMUTABLE
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
),
)
init {
@@ -57,9 +156,11 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.color = ContextCompat.getColor(context, R.color.blue_primary)
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
builder.setSilent(true)
builder.setGroup(GROUP_ID)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
}
fun create(state: DownloadState, timeLeft: Long): Notification {
fun notify(state: DownloadState, timeLeft: Long) {
builder.setContentTitle(state.manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true)
@@ -73,7 +174,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
NotificationCompat.VISIBILITY_PRIVATE
} else {
NotificationCompat.VISIBILITY_PUBLIC
}
},
)
when (state) {
is DownloadState.Cancelled -> {
@@ -82,6 +183,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setContentIntent(null)
builder.setStyle(null)
builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Done -> {
builder.setProgress(0, 0, false)
@@ -92,6 +194,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setCategory(null)
builder.setStyle(null)
builder.setOngoing(false)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Error -> {
val message = state.error.getDisplayMessage(context.resources)
@@ -107,12 +210,14 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.addAction(cancelAction)
builder.addAction(retryAction)
}
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.PostProcessing -> {
builder.setProgress(1, 0, true)
builder.setContentText(context.getString(R.string.processing_))
builder.setStyle(null)
builder.setOngoing(true)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Queued -> {
builder.setProgress(0, 0, false)
@@ -120,6 +225,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_LOW
}
is DownloadState.Preparing -> {
builder.setProgress(1, 0, true)
@@ -127,6 +233,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
is DownloadState.Progress -> {
builder.setProgress(state.max, state.progress, false)
@@ -141,29 +248,41 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
}
is DownloadState.WaitingForNetwork -> {
builder.setProgress(0, 0, false)
builder.setContentText(context.getString(R.string.waiting_for_network))
builder.setStyle(null)
builder.setOngoing(true)
builder.addAction(cancelAction)
builder.priority = NotificationCompat.PRIORITY_DEFAULT
}
}
return builder.build()
val notification = builder.build()
states.append(startId, state)
updateGroupNotification()
manager.notify(TAG, startId, notification)
}
fun dismiss() {
manager.cancel(TAG, startId)
states.remove(startId)
updateGroupNotification()
}
}
private fun updateGroupNotification() {
val notification = buildGroupNotification()
manager.notify(ID_GROUP, notification)
}
private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity(
context,
manga.hashCode(),
DetailsActivity.newIntent(context, manga),
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE,
)
companion object {
private const val TAG = "download"
private const val CHANNEL_ID = "download"
private const val GROUP_ID = "downloads"
private const val REQUEST_LIST = 6
const val ID_GROUP = 9999
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -172,7 +291,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.downloads),
NotificationManager.IMPORTANCE_LOW
NotificationManager.IMPORTANCE_LOW,
)
channel.enableVibration(false)
channel.enableLights(false)

View File

@@ -38,7 +38,7 @@ import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
class DownloadService : BaseService() {
private lateinit var downloadManager: DownloadManager
private lateinit var notificationSwitcher: ForegroundNotificationSwitcher
private lateinit var downloadNotification: DownloadNotification
private val jobs = LinkedHashMap<Int, PausingProgressJob<DownloadState>>()
private val jobCount = MutableStateFlow(0)
@@ -47,13 +47,14 @@ class DownloadService : BaseService() {
override fun onCreate() {
super.onCreate()
isRunning = true
notificationSwitcher = ForegroundNotificationSwitcher(this)
downloadNotification = DownloadNotification(this)
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
downloadManager = get<DownloadManager.Factory>().create(
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
)
DownloadNotification.createChannel(this)
startForeground(DownloadNotification.ID_GROUP, downloadNotification.buildGroupNotification())
val intentFilter = IntentFilter()
intentFilter.addAction(ACTION_DOWNLOAD_CANCEL)
intentFilter.addAction(ACTION_DOWNLOAD_RESUME)
@@ -80,6 +81,7 @@ class DownloadService : BaseService() {
}
override fun onDestroy() {
downloadNotification.dismiss()
unregisterReceiver(controlReceiver)
isRunning = false
super.onDestroy()
@@ -98,10 +100,10 @@ class DownloadService : BaseService() {
private fun listenJob(job: ProgressJob<DownloadState>) {
lifecycleScope.launch {
val startId = job.progressValue.startId
val notification = DownloadNotification(this@DownloadService, startId)
val notificationItem = downloadNotification.newItem(startId)
try {
val timeLeftEstimator = TimeLeftEstimator()
notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L))
notificationItem.notify(job.progressValue, -1L)
job.progressAsFlow()
.onEach { state ->
if (state is DownloadState.Progress) {
@@ -114,7 +116,7 @@ class DownloadService : BaseService() {
.whileActive()
.collect { state ->
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
notificationSwitcher.notify(startId, notification.create(state, timeLeft))
notificationItem.notify(state, timeLeft)
}
job.join()
} finally {
@@ -124,14 +126,11 @@ class DownloadService : BaseService() {
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)),
)
}
notificationSwitcher.detach(
startId,
if (job.isCancelled) {
null
notificationItem.dismiss()
} else {
notification.create(job.progressValue, -1L)
},
)
notificationItem.notify(job.progressValue, -1L)
}
stopSelf(startId)
}
}

View File

@@ -1,62 +0,0 @@
package org.koitharu.kotatsu.download.ui.service
import android.app.Notification
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.SparseArray
import androidx.core.app.ServiceCompat
import androidx.core.util.isEmpty
import androidx.core.util.size
private const val DEFAULT_DELAY = 500L
class ForegroundNotificationSwitcher(
private val service: Service,
) {
private val notificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val notifications = SparseArray<Notification>()
private val handler = Handler(Looper.getMainLooper())
@Synchronized
fun notify(startId: Int, notification: Notification) {
if (notifications.isEmpty()) {
service.startForeground(startId, notification)
} else {
notificationManager.notify(startId, notification)
}
notifications[startId] = notification
}
@Synchronized
fun detach(startId: Int, notification: Notification?) {
notifications.remove(startId)
if (notifications.isEmpty()) {
ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_DETACH)
}
val nextIndex = notifications.size - 1
if (nextIndex >= 0) {
val nextStartId = notifications.keyAt(nextIndex)
val nextNotification = notifications.valueAt(nextIndex)
service.startForeground(nextStartId, nextNotification)
}
handler.postDelayed(NotifyRunnable(startId, notification), DEFAULT_DELAY)
}
private inner class NotifyRunnable(
private val startId: Int,
private val notification: Notification?,
) : Runnable {
override fun run() {
if (notification != null) {
notificationManager.notify(startId, notification)
} else {
notificationManager.cancel(startId)
}
}
}
}

View File

@@ -317,4 +317,6 @@
<string name="data_deletion">Удаление данных</string>
<string name="clear_cookies_summary">Может помочь в случае каких-либо проблем. Все авторизации будут аннулированы</string>
<string name="show_all">Показать все</string>
<string name="downloading_manga">Downloading manga</string>
<string name="completed">Completed</string>
</resources>

View File

@@ -323,4 +323,7 @@
<string name="invalid_domain_message">Invalid domain</string>
<string name="select_range">Select range</string>
<string name="not_found_404">Content not found or removed</string>
<string name="downloading_manga">Downloading manga</string>
<string name="download_summary_pattern" translatable="false">&lt;b>%1$s&lt;/b> %2$s</string>
<string name="completed">Completed</string>
</resources>