From 9c740c5cc195f34d53c9d1d26df83084d5ba4ef5 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Wed, 10 Aug 2022 15:30:57 +0300 Subject: [PATCH 1/6] Fix settings title --- .../org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt index 052aba070..53e1af007 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsHeadersFragment.kt @@ -33,7 +33,7 @@ class SettingsHeadersFragment : PreferenceHeaderFragmentCompat(), SlidingPaneLay fun setTitle(title: CharSequence?) { currentTitle = title - if (slidingPaneLayout.isOpen) { + if (slidingPaneLayout.width != 0 && slidingPaneLayout.isOpen) { activity?.title = title } } From d5bea0ca5381e3394990e74bf6c0df4fe44586d9 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 9 Aug 2022 15:15:14 +0300 Subject: [PATCH 2/6] Fix DownloadService leak --- .../download/ui/service/DownloadService.kt | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index 96a3a4c55..993eab52e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -9,6 +9,8 @@ import android.os.IBinder import android.os.PowerManager import android.widget.Toast import androidx.core.content.ContextCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder import java.util.concurrent.TimeUnit @@ -41,7 +43,6 @@ class DownloadService : BaseService() { private val jobs = LinkedHashMap>() private val jobCount = MutableStateFlow(0) private val controlReceiver = ControlReceiver() - private var binder: DownloadBinder? = null override fun onCreate() { super.onCreate() @@ -50,7 +51,7 @@ class DownloadService : BaseService() { val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") downloadManager = get().create( - coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)) + coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)), ) DownloadNotification.createChannel(this) val intentFilter = IntentFilter() @@ -75,17 +76,11 @@ class DownloadService : BaseService() { override fun onBind(intent: Intent): IBinder { super.onBind(intent) - return binder ?: DownloadBinder(this).also { binder = it } - } - - override fun onUnbind(intent: Intent?): Boolean { - binder = null - return super.onUnbind(intent) + return DownloadBinder(this) } override fun onDestroy() { unregisterReceiver(controlReceiver) - binder = null isRunning = false super.onDestroy() } @@ -126,7 +121,7 @@ class DownloadService : BaseService() { (job.progressValue as? DownloadState.Done)?.let { sendBroadcast( Intent(ACTION_DOWNLOAD_COMPLETE) - .putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)) + .putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false)), ) } notificationSwitcher.detach( @@ -135,7 +130,7 @@ class DownloadService : BaseService() { null } else { notification.create(job.progressValue, -1L) - } + }, ) stopSelf(startId) } @@ -167,10 +162,25 @@ class DownloadService : BaseService() { } } - class DownloadBinder(private val service: DownloadService) : Binder() { + class DownloadBinder(service: DownloadService) : Binder(), DefaultLifecycleObserver { - val downloads: Flow>> - get() = service.jobCount.mapLatest { service.jobs.values } + private var downloadsStateFlow = MutableStateFlow>>(emptyList()) + + init { + service.lifecycle.addObserver(this) + service.jobCount.onEach { + downloadsStateFlow.value = service.jobs.values + }.launchIn(service.lifecycleScope) + } + + override fun onDestroy(owner: LifecycleOwner) { + owner.lifecycle.removeObserver(this) + downloadsStateFlow.value = emptyList() + super.onDestroy(owner) + } + + val downloads + get() = downloadsStateFlow.asStateFlow() } companion object { From 43ef13005221906731034f87b3ce12c062faa726 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 11 Aug 2022 11:40:27 +0300 Subject: [PATCH 3/6] Group download notification --- app/src/main/AndroidManifest.xml | 1 + .../download/domain/DownloadManager.kt | 18 +- .../kotatsu/download/domain/DownloadState.kt | 28 - .../kotatsu/download/ui/DownloadItemAD.kt | 10 +- .../ui/service/DownloadNotification.kt | 355 ++++++---- .../download/ui/service/DownloadService.kt | 25 +- .../service/ForegroundNotificationSwitcher.kt | 62 -- app/src/main/res/values-ru/strings.xml | 634 +++++++++--------- app/src/main/res/values/strings.xml | 3 + 9 files changed, 581 insertions(+), 555 deletions(-) delete mode 100644 app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 22bad7dd9..444c9619a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -111,6 +111,7 @@ { val stateFlow = MutableStateFlow( - 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, ) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt index d7f86abfb..343507b94 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadState.kt @@ -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, diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt index 476f6efb6..c91517b42 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt @@ -18,9 +18,8 @@ fun downloadItemAD( scope: CoroutineScope, coil: ImageLoader, ) = adapterDelegateViewBinding, ProgressJob, 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) } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt index 399340aa3..12efd0d34 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt @@ -7,163 +7,282 @@ 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() + private val groupBuilder = NotificationCompat.Builder(context, CHANNEL_ID) - private val builder = NotificationCompat.Builder(context, CHANNEL_ID) - private val cancelAction = NotificationCompat.Action( - materialR.drawable.material_ic_clear_black_24dp, - context.getString(android.R.string.cancel), - PendingIntent.getBroadcast( - context, - startId * 2, - DownloadService.getCancelIntent(startId), - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE - ) - ) - private val retryAction = NotificationCompat.Action( - R.drawable.ic_restart_black, - context.getString(R.string.try_again), - PendingIntent.getBroadcast( - 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 + PendingIntentCompat.FLAG_IMMUTABLE, ) init { - builder.setOnlyAlertOnce(true) - builder.setDefaults(0) - builder.color = ContextCompat.getColor(context, R.color.blue_primary) - builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE - builder.setSilent(true) + 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 create(state: DownloadState, timeLeft: Long): Notification { - builder.setContentTitle(state.manga.title) - builder.setContentText(context.getString(R.string.manga_downloading_)) - builder.setProgress(1, 0, true) - builder.setSmallIcon(android.R.drawable.stat_sys_download) - builder.setContentIntent(listIntent) - builder.setStyle(null) - builder.setLargeIcon(state.cover?.toBitmap()) - builder.clearActions() - builder.setVisibility( + 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) { - NotificationCompat.VISIBILITY_PRIVATE - } else { - NotificationCompat.VISIBILITY_PUBLIC + groupBuilder.setVisibility(NotificationCompat.VISIBILITY_PRIVATE) } - ) - when (state) { - is DownloadState.Cancelled -> { - builder.setProgress(1, 0, true) - builder.setContentText(context.getString(R.string.cancelling_)) - builder.setContentIntent(null) - builder.setStyle(null) - builder.setOngoing(true) - } - is DownloadState.Done -> { - builder.setProgress(0, 0, false) - builder.setContentText(context.getString(R.string.download_complete)) - builder.setContentIntent(createMangaIntent(context, state.localManga)) - builder.setAutoCancel(true) - builder.setSmallIcon(android.R.drawable.stat_sys_download_done) - builder.setCategory(null) - builder.setStyle(null) - builder.setOngoing(false) - } - is DownloadState.Error -> { - val message = state.error.getDisplayMessage(context.resources) - builder.setProgress(0, 0, false) - builder.setSmallIcon(android.R.drawable.stat_notify_error) - builder.setSubText(context.getString(R.string.error)) - builder.setContentText(message) - builder.setAutoCancel(!state.canRetry) - builder.setOngoing(state.canRetry) - builder.setCategory(NotificationCompat.CATEGORY_ERROR) - builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) - if (state.canRetry) { - builder.addAction(cancelAction) - builder.addAction(retryAction) + 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) } } - is DownloadState.PostProcessing -> { - builder.setProgress(1, 0, true) - builder.setContentText(context.getString(R.string.processing_)) - builder.setStyle(null) - builder.setOngoing(true) - } - is DownloadState.Queued -> { - builder.setProgress(0, 0, false) - builder.setContentText(context.getString(R.string.queued)) - builder.setStyle(null) - builder.setOngoing(true) - builder.addAction(cancelAction) - } - is DownloadState.Preparing -> { - builder.setProgress(1, 0, true) - builder.setContentText(context.getString(R.string.preparing_)) - builder.setStyle(null) - builder.setOngoing(true) - builder.addAction(cancelAction) - } - is DownloadState.Progress -> { - builder.setProgress(state.max, state.progress, false) - if (timeLeft > 0L) { - val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS) - builder.setContentText(eta) - } else { - val percent = (state.percent * 100).format() - builder.setContentText(context.getString(R.string.percent_string_pattern, percent)) - } - builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) - 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) - } + style.addLine( + context.getString( + R.string.download_summary_pattern, + state.manga.title.ellipsize(10).htmlEncode(), + summary.htmlEncode(), + ).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY), + ) } - return builder.build() + 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( + materialR.drawable.material_ic_clear_black_24dp, + context.getString(android.R.string.cancel), + PendingIntent.getBroadcast( + context, + startId * 2, + DownloadService.getCancelIntent(startId), + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE, + ), + ) + private val retryAction = NotificationCompat.Action( + R.drawable.ic_restart_black, + context.getString(R.string.try_again), + PendingIntent.getBroadcast( + context, + startId * 2 + 1, + DownloadService.getResumeIntent(startId), + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE, + ), + ) + + init { + builder.setOnlyAlertOnce(true) + builder.setDefaults(0) + 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 notify(state: DownloadState, timeLeft: Long) { + builder.setContentTitle(state.manga.title) + builder.setContentText(context.getString(R.string.manga_downloading_)) + builder.setProgress(1, 0, true) + builder.setSmallIcon(android.R.drawable.stat_sys_download) + builder.setContentIntent(listIntent) + builder.setStyle(null) + builder.setLargeIcon(state.cover?.toBitmap()) + builder.clearActions() + builder.setVisibility( + if (state.manga.isNsfw) { + NotificationCompat.VISIBILITY_PRIVATE + } else { + NotificationCompat.VISIBILITY_PUBLIC + }, + ) + when (state) { + is DownloadState.Cancelled -> { + builder.setProgress(1, 0, true) + builder.setContentText(context.getString(R.string.cancelling_)) + builder.setContentIntent(null) + builder.setStyle(null) + builder.setOngoing(true) + builder.priority = NotificationCompat.PRIORITY_DEFAULT + } + is DownloadState.Done -> { + builder.setProgress(0, 0, false) + builder.setContentText(context.getString(R.string.download_complete)) + builder.setContentIntent(createMangaIntent(context, state.localManga)) + builder.setAutoCancel(true) + builder.setSmallIcon(android.R.drawable.stat_sys_download_done) + builder.setCategory(null) + builder.setStyle(null) + builder.setOngoing(false) + builder.priority = NotificationCompat.PRIORITY_DEFAULT + } + is DownloadState.Error -> { + val message = state.error.getDisplayMessage(context.resources) + builder.setProgress(0, 0, false) + builder.setSmallIcon(android.R.drawable.stat_notify_error) + builder.setSubText(context.getString(R.string.error)) + builder.setContentText(message) + builder.setAutoCancel(!state.canRetry) + builder.setOngoing(state.canRetry) + builder.setCategory(NotificationCompat.CATEGORY_ERROR) + builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) + if (state.canRetry) { + 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) + builder.setContentText(context.getString(R.string.queued)) + builder.setStyle(null) + builder.setOngoing(true) + builder.addAction(cancelAction) + builder.priority = NotificationCompat.PRIORITY_LOW + } + is DownloadState.Preparing -> { + builder.setProgress(1, 0, true) + builder.setContentText(context.getString(R.string.preparing_)) + builder.setStyle(null) + builder.setOngoing(true) + builder.addAction(cancelAction) + builder.priority = NotificationCompat.PRIORITY_DEFAULT + } + is DownloadState.Progress -> { + builder.setProgress(state.max, state.progress, false) + if (timeLeft > 0L) { + val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS) + builder.setContentText(eta) + } else { + val percent = (state.percent * 100).format() + builder.setContentText(context.getString(R.string.percent_string_pattern, percent)) + } + builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) + builder.setStyle(null) + builder.setOngoing(true) + builder.addAction(cancelAction) + builder.priority = NotificationCompat.PRIORITY_DEFAULT + } + } + 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) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index 993eab52e..d75082e2a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -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>() 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().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) { 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 - } else { - notification.create(job.progressValue, -1L) - }, - ) + if (job.isCancelled) { + notificationItem.dismiss() + } else { + notificationItem.notify(job.progressValue, -1L) + } stopSelf(startId) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt deleted file mode 100644 index 679405295..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt +++ /dev/null @@ -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() - 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) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 07ab4f6bb..ab2697193 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,320 +1,322 @@ - Закрыть меню - Открыть меню - На устройстве - Избранное - История - Произошла ошибка - Не удалось подключиться к интернету - Подробности - Главы - Список - Подробный список - Таблица - Вид списка - Настройки - Онлайн каталоги - Загрузка… - Глава %1$d из %2$d - Закрыть - Повторить - Очистить историю - Ничего не найдено - Истории пока нет - Читать - Избранного пока нет - В избранное - Новая категория - Добавить - Введите название - Сохранить - Поделиться - Создать ярлык… - Поделиться %s - Поиск - Поиск манги - Загрузка… - Обработка… - Загружено - Загрузки - Имя - Популярная - Обновлённая - Новая - Рейтинг - Порядок сортировки - Фильтр - Тема - Светлая - Тёмная - Как в системе - Страницы - Очистить - Очистить всю историю чтения полностью\? - Удалить - «%s» удалено из истории - «%s» удалено с устройства - Дождитесь завершения загрузки… - Сохранить страницу - Сохранено - Поделиться изображением - Импорт - Удалить - Операция не поддерживается - Выберите CBZ-файл или ZIP - Нет описания - История и кэш - Очистить кэш страниц - Кэш - Б|кБ|МБ|ГБ|ТБ - Стандартный - Манхва - Режим чтения - Размер таблицы - Поиск по %s - Удалить мангу - Удалить \"%s\" с устройства навсегда\? - Настройки чтения - Листание страниц - Нажатия по краям - Кнопки громкости - Продолжить - Предупреждение - Это может привести к расходу большого количества трафика - Больше не спрашивать - Отмена… - Ошибка - Очистить кэш миниатюр - Очистить историю поиска - Очищено - Только жесты - Внутренний накопитель - Внешнее хранилище - Домен - Проверять наличие новых версий приложения - Доступна новая версия приложения - Показывать уведомление, если доступна новая версия - Открыть в веб-браузере - В этой манге %s. Сохранить их все\? - Сохранить - Уведомления - Включено %1$d из %2$d - Новые главы - Загрузить - Читать с начала - Перезапустить - Настройки уведомлений - Звук уведомления - Светодиодная индикация - Вибросигнал - Категории избранного - Категории… - Переименовать - Удалить категорию \"%s\" из избранного\? + Закрыть меню + Открыть меню + На устройстве + Избранное + История + Произошла ошибка + Не удалось подключиться к интернету + Подробности + Главы + Список + Подробный список + Таблица + Вид списка + Настройки + Онлайн каталоги + Загрузка… + Глава %1$d из %2$d + Закрыть + Повторить + Очистить историю + Ничего не найдено + Истории пока нет + Читать + Избранного пока нет + В избранное + Новая категория + Добавить + Введите название + Сохранить + Поделиться + Создать ярлык… + Поделиться %s + Поиск + Поиск манги + Загрузка… + Обработка… + Загружено + Загрузки + Имя + Популярная + Обновлённая + Новая + Рейтинг + Порядок сортировки + Фильтр + Тема + Светлая + Тёмная + Как в системе + Страницы + Очистить + Очистить всю историю чтения полностью\? + Удалить + «%s» удалено из истории + «%s» удалено с устройства + Дождитесь завершения загрузки… + Сохранить страницу + Сохранено + Поделиться изображением + Импорт + Удалить + Операция не поддерживается + Выберите CBZ-файл или ZIP + Нет описания + История и кэш + Очистить кэш страниц + Кэш + Б|кБ|МБ|ГБ|ТБ + Стандартный + Манхва + Режим чтения + Размер таблицы + Поиск по %s + Удалить мангу + Удалить \"%s\" с устройства навсегда\? + Настройки чтения + Листание страниц + Нажатия по краям + Кнопки громкости + Продолжить + Предупреждение + Это может привести к расходу большого количества трафика + Больше не спрашивать + Отмена… + Ошибка + Очистить кэш миниатюр + Очистить историю поиска + Очищено + Только жесты + Внутренний накопитель + Внешнее хранилище + Домен + Проверять наличие новых версий приложения + Доступна новая версия приложения + Показывать уведомление, если доступна новая версия + Открыть в веб-браузере + В этой манге %s. Сохранить их все\? + Сохранить + Уведомления + Включено %1$d из %2$d + Новые главы + Загрузить + Читать с начала + Перезапустить + Настройки уведомлений + Звук уведомления + Светодиодная индикация + Вибросигнал + Категории избранного + Категории… + Переименовать + Удалить категорию \"%s\" из избранного\? \nВся манга в ней будет потеряна. - Удалить - Как-то здесь пусто… - Попробуйте переформулировать запрос. - Вы можете использовать категории для организации своих избранных. Нажмите «+», чтобы создать категорию - То, что вы прочитаете, будет отображено здесь - Найдите, что почитать, в боковом меню. - Сохраните что-нибудь - Сохраните что-нибудь из онлайн-каталога или импортируйте из файла. - Полка - Недавнее - Анимация листания - Папка для загрузок - Недоступно - Нет доступного хранилища - Другое хранилище - Готово - Всё избранное - Категория пуста - Прочитать позже - Обновления - Новые главы из того, что вы читаете, будут показаны здесь - Результаты поиска - Похожие - Новая версия: %s - Размер: %s - Ожидание подключения… - Очистить ленту обновлений - Очищено - Повернуть экран - Обновить - Обновление скоро начнётся - Следить за обновлениями - Не проверять - Введите пароль - Неверный пароль - Защитить приложение - Запрашивать пароль при запуске Kotatsu - Повторите пароль - Пароли не совпадают - О программе - Версия %s - Проверить обновления - Проверка обновления… - Не удалось проверить обновления - Нет доступных обновлений - Справа налево (←) - Создать категорию - Масштабирование - Вписать в экран - Подогнать по высоте - Подогнать по ширине - Исходный размер - Чёрная - Потребляет меньше энергии на экранах AMOLED - Резервное копирование и восстановление - Создать резервную копию - Восстановить данные - Восстановлено - Подготовка… - Файл не найден - Все данные были восстановлены - Данные были восстановлены, но возникли некоторые ошибки - Вы можете создать резервную копию избранного и истории и потом восстановить их - Только что - Вчера - Давно - Группировать - Сегодня - Попробовать ещё раз - Выбранный режим будет сохранён для текущей манги - Без звука - Необходимо пройти CAPTCHA - Пройти - Очистить куки - Все файлы cookie были удалены - Проверка новых глав: %1$d из %2$d - Очистить ленту - Удалить всю историю обновлений навсегда\? - Проверка новых глав - В обратном порядке - Войти - Авторизуйтесь, чтобы просмотреть этот контент - По умолчанию: %s - …и ещё %1$d - Далее - Введите пароль для запуска приложения - Подтвердить - Пароль должен состоять из 4 символов или более - Поиск только по %s - Другие - Добро пожаловать - Удалить все последние поисковые запросы навсегда\? - Резервная копия сохранена - Некоторые устройства имеют различное поведение системы, что может привести к нарушению фоновых задач. - Подробнее - В очереди - Нет активных загрузок - Глава отсутствует - Скачайте или прочитайте эту недостающую главу онлайн. - Помочь с переводом приложения - Перевод - Тема на 4PDA - Обратная связь - Авторизация выполнена - Вход в %s не поддерживается - Вы выйдете из всех источников - Жанры - Завершено - Онгоинг - Формат даты - По умолчанию - Исключить NSFW мангу из истории - Вы должны ввести имя - Показывать номера страницы - Включенные источники - Доступные источники - Динамическая тема - Применяет тему приложения, основанную на цветовой палитре обоев на устройстве - Политика скриншотов - Разрешить - Запретить для NSFW - Всегда блокировать - Рекомендации - Включить рекомендации - Предлагать мангу на основе Ваших предпочтений - Все данные анализируются локально на устройстве. Ваши персональные данные не передаются в какие-либо сервисы - Начните читать мангу, чтобы получать персональные предложения - Не предлагать NSFW мангу - Включено - Выключено - Не удалось загрузить список жанров - Вычисление… - Создать проблему на GitHub - Импорт манги: %1$d из %2$d - Сбросить фильтр - Поиск по жанрам - Выберите языки, на которых Вы хоите читать мангу. Это можно будет изменить позже в настройках. - Никогда - Только по Wi-Fi - Всегда - Предварительная загрузка страниц - Вы авторизованы как %s - 18+ - Разные языки - Найти главу - В этой манге нет глав - Оформление - Контент - Обновление рекомендаций - Исключить жанры - Укажите жанры, которые Вы не хотите видеть в рекомендациях - Удалить выбранную мангу с накопителя? - Удаление завершено - Загрузить выбранную мангу со всеми главами? Это может привести к большому расходу трафика и места на накопителе - Загружать параллельно - Замедление загрузки - Помогает избежать блокировки IP-адреса - Обработка сохранённой манги - Главы будут удалены в фоновом режиме. Это может занять какое-то время - Скрыть - Доступны новые источники манги - Проверять новые главы и уведомлять о них - Вы будете получать уведомления об обновлении манги, которую Вы читаете - Вы не будете получать уведомления, но новые главы будут отображаться в списке - Включить уведомления - Название - Изменить - Изменить категорию - Отслеживание - Нет категорий избранного - Добавить закладку - Удалить закладку - Закладки - Закладка удалена - Закладка добавлена - Отменить - Удалено из истории - DNS через HTTPS - Режим по умолчанию - Автоопределение режима чтения - Автоматически определяет, является ли манга веб-комиксом - Отключить оптимизацию батареи - Помогает с фоновой проверкой обновлений - Что-то пошло не так. Пожалуйста, отправьте отчёт разработчикам, чтобы помочь всё исправить - Отправить - Отключить все - Использовать отпечаток пальца, если доступно - Манга из Вашего избранного - Манга, которую Вы недавно читали - Читаю - Запланировано - Отложено - Заброшено - Завершено - Показать процент прочитанного в истории и избранном - Манга, помеченная как NSFW, никогда не будет добавлена в историю и ваш прогресс чтения не будет сохранен - %1$s%% - Отчёт - Выйти - Перечитываю - Показать индикаторы прогресса чтения - Удаление данных - Может помочь в случае каких-либо проблем. Все авторизации будут аннулированы - Показать все + Удалить + Как-то здесь пусто… + Попробуйте переформулировать запрос. + Вы можете использовать категории для организации своих избранных. Нажмите «+», чтобы создать категорию + То, что вы прочитаете, будет отображено здесь + Найдите, что почитать, в боковом меню. + Сохраните что-нибудь + Сохраните что-нибудь из онлайн-каталога или импортируйте из файла. + Полка + Недавнее + Анимация листания + Папка для загрузок + Недоступно + Нет доступного хранилища + Другое хранилище + Готово + Всё избранное + Категория пуста + Прочитать позже + Обновления + Новые главы из того, что вы читаете, будут показаны здесь + Результаты поиска + Похожие + Новая версия: %s + Размер: %s + Ожидание подключения… + Очистить ленту обновлений + Очищено + Повернуть экран + Обновить + Обновление скоро начнётся + Следить за обновлениями + Не проверять + Введите пароль + Неверный пароль + Защитить приложение + Запрашивать пароль при запуске Kotatsu + Повторите пароль + Пароли не совпадают + О программе + Версия %s + Проверить обновления + Проверка обновления… + Не удалось проверить обновления + Нет доступных обновлений + Справа налево (←) + Создать категорию + Масштабирование + Вписать в экран + Подогнать по высоте + Подогнать по ширине + Исходный размер + Чёрная + Потребляет меньше энергии на экранах AMOLED + Резервное копирование и восстановление + Создать резервную копию + Восстановить данные + Восстановлено + Подготовка… + Файл не найден + Все данные были восстановлены + Данные были восстановлены, но возникли некоторые ошибки + Вы можете создать резервную копию избранного и истории и потом восстановить их + Только что + Вчера + Давно + Группировать + Сегодня + Попробовать ещё раз + Выбранный режим будет сохранён для текущей манги + Без звука + Необходимо пройти CAPTCHA + Пройти + Очистить куки + Все файлы cookie были удалены + Проверка новых глав: %1$d из %2$d + Очистить ленту + Удалить всю историю обновлений навсегда\? + Проверка новых глав + В обратном порядке + Войти + Авторизуйтесь, чтобы просмотреть этот контент + По умолчанию: %s + …и ещё %1$d + Далее + Введите пароль для запуска приложения + Подтвердить + Пароль должен состоять из 4 символов или более + Поиск только по %s + Другие + Добро пожаловать + Удалить все последние поисковые запросы навсегда\? + Резервная копия сохранена + Некоторые устройства имеют различное поведение системы, что может привести к нарушению фоновых задач. + Подробнее + В очереди + Нет активных загрузок + Глава отсутствует + Скачайте или прочитайте эту недостающую главу онлайн. + Помочь с переводом приложения + Перевод + Тема на 4PDA + Обратная связь + Авторизация выполнена + Вход в %s не поддерживается + Вы выйдете из всех источников + Жанры + Завершено + Онгоинг + Формат даты + По умолчанию + Исключить NSFW мангу из истории + Вы должны ввести имя + Показывать номера страницы + Включенные источники + Доступные источники + Динамическая тема + Применяет тему приложения, основанную на цветовой палитре обоев на устройстве + Политика скриншотов + Разрешить + Запретить для NSFW + Всегда блокировать + Рекомендации + Включить рекомендации + Предлагать мангу на основе Ваших предпочтений + Все данные анализируются локально на устройстве. Ваши персональные данные не передаются в какие-либо сервисы + Начните читать мангу, чтобы получать персональные предложения + Не предлагать NSFW мангу + Включено + Выключено + Не удалось загрузить список жанров + Вычисление… + Создать проблему на GitHub + Импорт манги: %1$d из %2$d + Сбросить фильтр + Поиск по жанрам + Выберите языки, на которых Вы хоите читать мангу. Это можно будет изменить позже в настройках. + Никогда + Только по Wi-Fi + Всегда + Предварительная загрузка страниц + Вы авторизованы как %s + 18+ + Разные языки + Найти главу + В этой манге нет глав + Оформление + Контент + Обновление рекомендаций + Исключить жанры + Укажите жанры, которые Вы не хотите видеть в рекомендациях + Удалить выбранную мангу с накопителя? + Удаление завершено + Загрузить выбранную мангу со всеми главами? Это может привести к большому расходу трафика и места на накопителе + Загружать параллельно + Замедление загрузки + Помогает избежать блокировки IP-адреса + Обработка сохранённой манги + Главы будут удалены в фоновом режиме. Это может занять какое-то время + Скрыть + Доступны новые источники манги + Проверять новые главы и уведомлять о них + Вы будете получать уведомления об обновлении манги, которую Вы читаете + Вы не будете получать уведомления, но новые главы будут отображаться в списке + Включить уведомления + Название + Изменить + Изменить категорию + Отслеживание + Нет категорий избранного + Добавить закладку + Удалить закладку + Закладки + Закладка удалена + Закладка добавлена + Отменить + Удалено из истории + DNS через HTTPS + Режим по умолчанию + Автоопределение режима чтения + Автоматически определяет, является ли манга веб-комиксом + Отключить оптимизацию батареи + Помогает с фоновой проверкой обновлений + Что-то пошло не так. Пожалуйста, отправьте отчёт разработчикам, чтобы помочь всё исправить + Отправить + Отключить все + Использовать отпечаток пальца, если доступно + Манга из Вашего избранного + Манга, которую Вы недавно читали + Читаю + Запланировано + Отложено + Заброшено + Завершено + Показать процент прочитанного в истории и избранном + Манга, помеченная как NSFW, никогда не будет добавлена в историю и ваш прогресс чтения не будет сохранен + %1$s%% + Отчёт + Выйти + Перечитываю + Показать индикаторы прогресса чтения + Удаление данных + Может помочь в случае каких-либо проблем. Все авторизации будут аннулированы + Показать все + Downloading manga + Completed \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a34f0fc89..d64335ae2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -323,4 +323,7 @@ Invalid domain Select range Content not found or removed + Downloading manga + <b>%1$s</b> %2$s + Completed \ No newline at end of file From 893d1a881deee39ad50943ed119b984af14fbdbc Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 11 Aug 2022 12:15:24 +0300 Subject: [PATCH 4/6] Fix download service stopping #210 --- .../ui/service/DownloadNotification.kt | 9 ++++++--- .../download/ui/service/DownloadService.kt | 20 +++++++++++++++---- app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt index 12efd0d34..175376ed0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt @@ -17,6 +17,7 @@ import androidx.core.text.htmlEncode import androidx.core.text.parseAsHtml import androidx.core.util.forEach import androidx.core.util.size +import com.google.android.material.R as materialR import org.koitharu.kotatsu.R import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.download.domain.DownloadState @@ -26,7 +27,6 @@ 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) { @@ -70,7 +70,7 @@ class DownloadNotification(private val context: Context) { } is DownloadState.Done -> { progress++ - context.getString(R.string.completed) + context.getString(R.string.download_complete) } is DownloadState.Error -> { isAllDone = false @@ -118,8 +118,10 @@ class DownloadNotification(private val context: Context) { return groupBuilder.build() } - fun dismiss() { + fun detach() { manager.cancel(ID_GROUP) + val notification = buildGroupNotification() + manager.notify(ID_GROUP_DETACHED, notification) } fun newItem(startId: Int) = Item(startId) @@ -283,6 +285,7 @@ class DownloadNotification(private val context: Context) { private const val GROUP_ID = "downloads" private const val REQUEST_LIST = 6 const val ID_GROUP = 9999 + private const val ID_GROUP_DETACHED = 9998 fun createChannel(context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index d75082e2a..3c6a60b4f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -8,6 +8,8 @@ import android.os.Binder import android.os.IBinder import android.os.PowerManager import android.widget.Toast +import androidx.annotation.MainThread +import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -48,7 +50,7 @@ class DownloadService : BaseService() { super.onCreate() isRunning = true downloadNotification = DownloadNotification(this) - val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) + val wakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") downloadManager = get().create( coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)), @@ -70,7 +72,7 @@ class DownloadService : BaseService() { jobCount.value = jobs.size START_REDELIVER_INTENT } else { - stopSelf(startId) + stopSelfIfIdle() START_NOT_STICKY } } @@ -81,7 +83,6 @@ class DownloadService : BaseService() { } override fun onDestroy() { - downloadNotification.dismiss() unregisterReceiver(controlReceiver) isRunning = false super.onDestroy() @@ -131,8 +132,9 @@ class DownloadService : BaseService() { } else { notificationItem.notify(job.progressValue, -1L) } - stopSelf(startId) } + }.invokeOnCompletion { + stopSelfIfIdle() } } @@ -144,6 +146,16 @@ class DownloadService : BaseService() { private val DownloadState.isTerminal: Boolean get() = this is DownloadState.Done || this is DownloadState.Cancelled || (this is DownloadState.Error && !canRetry) + @MainThread + private fun stopSelfIfIdle() { + if (jobs.any { (_, job) -> job.isActive }) { + return + } + downloadNotification.detach() + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() + } + inner class ControlReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ab2697193..1f4566f1d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -318,5 +318,4 @@ Может помочь в случае каких-либо проблем. Все авторизации будут аннулированы Показать все Downloading manga - Completed \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d64335ae2..f33ed79f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -325,5 +325,4 @@ Content not found or removed Downloading manga <b>%1$s</b> %2$s - Completed \ No newline at end of file From c07a3b9d0d41a8005438d37e0e17abe7d2b5e262 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 11 Aug 2022 12:48:59 +0300 Subject: [PATCH 5/6] Download control buttons in list --- .../kotatsu/download/ui/DownloadItemAD.kt | 32 +++++++++++++++++-- .../kotatsu/download/ui/DownloadsAdapter.kt | 16 ++++++---- .../ui/service/DownloadNotification.kt | 22 +++++++++---- .../download/ui/service/DownloadService.kt | 2 ++ app/src/main/res/layout/item_download.xml | 28 +++++++++++++++- app/src/main/res/values-ru/strings.xml | 1 - 6 files changed, 84 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt index c91517b42..d568e2547 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.download.ui +import android.view.View import androidx.core.view.isVisible import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding @@ -9,20 +10,33 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.koitharu.kotatsu.R import org.koitharu.kotatsu.databinding.ItemDownloadBinding +import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.utils.ext.* -import org.koitharu.kotatsu.utils.progress.ProgressJob fun downloadItemAD( scope: CoroutineScope, coil: ImageLoader, -) = adapterDelegateViewBinding, ProgressJob, ItemDownloadBinding>( +) = adapterDelegateViewBinding( { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }, ) { var job: Job? = null val percentPattern = context.resources.getString(R.string.percent_string_pattern) + val clickListener = View.OnClickListener { v -> + when (v.id) { + R.id.button_cancel -> item.cancel() + R.id.button_resume -> item.resume() + else -> context.startActivity( + DetailsActivity.newIntent(context, item.progressValue.manga), + ) + } + } + binding.buttonCancel.setOnClickListener(clickListener) + binding.buttonResume.setOnClickListener(clickListener) + itemView.setOnClickListener(clickListener) + bind { job?.cancel() job = item.progressAsFlow().onFirst { state -> @@ -43,6 +57,8 @@ fun downloadItemAD( binding.progressBar.isVisible = true binding.textViewPercent.isVisible = false binding.textViewDetails.isVisible = false + binding.buttonCancel.isVisible = false + binding.buttonResume.isVisible = false } is DownloadState.Done -> { binding.textViewStatus.setText(R.string.download_complete) @@ -50,6 +66,8 @@ fun downloadItemAD( binding.progressBar.isVisible = false binding.textViewPercent.isVisible = false binding.textViewDetails.isVisible = false + binding.buttonCancel.isVisible = false + binding.buttonResume.isVisible = false } is DownloadState.Error -> { binding.textViewStatus.setText(R.string.error_occurred) @@ -58,6 +76,8 @@ fun downloadItemAD( binding.textViewPercent.isVisible = false binding.textViewDetails.text = state.error.getDisplayMessage(context.resources) binding.textViewDetails.isVisible = true + binding.buttonCancel.isVisible = state.canRetry + binding.buttonResume.isVisible = state.canRetry } is DownloadState.PostProcessing -> { binding.textViewStatus.setText(R.string.processing_) @@ -65,6 +85,8 @@ fun downloadItemAD( binding.progressBar.isVisible = true binding.textViewPercent.isVisible = false binding.textViewDetails.isVisible = false + binding.buttonCancel.isVisible = false + binding.buttonResume.isVisible = false } is DownloadState.Preparing -> { binding.textViewStatus.setText(R.string.preparing_) @@ -72,6 +94,8 @@ fun downloadItemAD( binding.progressBar.isVisible = true binding.textViewPercent.isVisible = false binding.textViewDetails.isVisible = false + binding.buttonCancel.isVisible = true + binding.buttonResume.isVisible = false } is DownloadState.Progress -> { binding.textViewStatus.setText(R.string.manga_downloading_) @@ -82,6 +106,8 @@ fun downloadItemAD( binding.textViewPercent.text = percentPattern.format((state.percent * 100f).format(1)) binding.textViewPercent.isVisible = true binding.textViewDetails.isVisible = false + binding.buttonCancel.isVisible = true + binding.buttonResume.isVisible = false } is DownloadState.Queued -> { binding.textViewStatus.setText(R.string.queued) @@ -89,6 +115,8 @@ fun downloadItemAD( binding.progressBar.isVisible = false binding.textViewPercent.isVisible = false binding.textViewDetails.isVisible = false + binding.buttonCancel.isVisible = true + binding.buttonResume.isVisible = false } } }.launchIn(scope) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt index 0c3629d1b..eda0c1a1e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt @@ -5,12 +5,14 @@ import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import kotlinx.coroutines.CoroutineScope import org.koitharu.kotatsu.download.domain.DownloadState -import org.koitharu.kotatsu.utils.progress.ProgressJob +import org.koitharu.kotatsu.utils.progress.PausingProgressJob + +typealias DownloadItem = PausingProgressJob class DownloadsAdapter( scope: CoroutineScope, coil: ImageLoader, -) : AsyncListDifferDelegationAdapter>(DiffCallback()) { +) : AsyncListDifferDelegationAdapter(DiffCallback()) { init { delegatesManager.addDelegate(downloadItemAD(scope, coil)) @@ -21,18 +23,18 @@ class DownloadsAdapter( return items[position].progressValue.startId.toLong() } - private class DiffCallback : DiffUtil.ItemCallback>() { + private class DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame( - oldItem: ProgressJob, - newItem: ProgressJob, + oldItem: DownloadItem, + newItem: DownloadItem, ): Boolean { return oldItem.progressValue.startId == newItem.progressValue.startId } override fun areContentsTheSame( - oldItem: ProgressJob, - newItem: ProgressJob, + oldItem: DownloadItem, + newItem: DownloadItem, ): Boolean { return oldItem.progressValue == newItem.progressValue } diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt index 175376ed0..0aac1895e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt @@ -16,6 +16,7 @@ import androidx.core.text.HtmlCompat import androidx.core.text.htmlEncode import androidx.core.text.parseAsHtml import androidx.core.util.forEach +import androidx.core.util.isNotEmpty import androidx.core.util.size import com.google.android.material.R as materialR import org.koitharu.kotatsu.R @@ -98,7 +99,7 @@ class DownloadNotification(private val context: Context) { style.addLine( context.getString( R.string.download_summary_pattern, - state.manga.title.ellipsize(10).htmlEncode(), + state.manga.title.ellipsize(16).htmlEncode(), summary.htmlEncode(), ).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY), ) @@ -113,15 +114,17 @@ class DownloadNotification(private val context: Context) { when (progress) { 1f -> groupBuilder.setProgress(0, 0, false) 0f -> groupBuilder.setProgress(1, 0, true) - else -> groupBuilder.setProgress(100, (progress * 100f).toInt(), progress == 0f) + else -> groupBuilder.setProgress(100, (progress * 100f).toInt(), false) } return groupBuilder.build() } fun detach() { manager.cancel(ID_GROUP) - val notification = buildGroupNotification() - manager.notify(ID_GROUP_DETACHED, notification) + if (states.isNotEmpty() && Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + val notification = buildGroupNotification() + manager.notify(ID_GROUP_DETACHED, notification) + } } fun newItem(startId: Int) = Item(startId) @@ -171,6 +174,8 @@ class DownloadNotification(private val context: Context) { builder.setStyle(null) builder.setLargeIcon(state.cover?.toBitmap()) builder.clearActions() + builder.setSubText(null) + builder.setShowWhen(false) builder.setVisibility( if (state.manga.isNsfw) { NotificationCompat.VISIBILITY_PRIVATE @@ -196,6 +201,8 @@ class DownloadNotification(private val context: Context) { builder.setCategory(null) builder.setStyle(null) builder.setOngoing(false) + builder.setShowWhen(true) + builder.setWhen(System.currentTimeMillis()) builder.priority = NotificationCompat.PRIORITY_DEFAULT } is DownloadState.Error -> { @@ -207,6 +214,8 @@ class DownloadNotification(private val context: Context) { builder.setAutoCancel(!state.canRetry) builder.setOngoing(state.canRetry) builder.setCategory(NotificationCompat.CATEGORY_ERROR) + builder.setShowWhen(true) + builder.setWhen(System.currentTimeMillis()) builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) if (state.canRetry) { builder.addAction(cancelAction) @@ -239,12 +248,13 @@ class DownloadNotification(private val context: Context) { } is DownloadState.Progress -> { builder.setProgress(state.max, state.progress, false) + val percent = context.getString(R.string.percent_string_pattern, (state.percent * 100).format()) if (timeLeft > 0L) { val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS) builder.setContentText(eta) + builder.setSubText(percent) } else { - val percent = (state.percent * 100).format() - builder.setContentText(context.getString(R.string.percent_string_pattern, percent)) + builder.setContentText(percent) } builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setStyle(null) diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt index 3c6a60b4f..e8382f17f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -129,6 +129,8 @@ class DownloadService : BaseService() { } if (job.isCancelled) { notificationItem.dismiss() + jobs.remove(startId) + jobCount.value = jobs.size } else { notificationItem.notify(job.progressValue, -1L) } diff --git a/app/src/main/res/layout/item_download.xml b/app/src/main/res/layout/item_download.xml index 101df297e..6d5e549be 100644 --- a/app/src/main/res/layout/item_download.xml +++ b/app/src/main/res/layout/item_download.xml @@ -77,10 +77,10 @@ android:id="@+id/textView_percent" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_marginEnd="8dp" android:textAppearance="?attr/textAppearanceBodyMedium" app:layout_constraintBaseline_toBaselineOf="@id/textView_status" app:layout_constraintEnd_toEndOf="parent" - android:layout_marginEnd="8dp" tools:text="25%" /> +