From 0646a782ab8bff0817dae91708c0749fe5e2c4b2 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Wed, 5 Feb 2025 15:19:43 +0530 Subject: [PATCH 1/2] Fixed: Download notification disappears when pausing a download. * The Fetch library automatically removes the ongoing download notification when a download is paused. To improve the user experience, we have implemented custom logic to display a notification for paused downloads, allowing users to easily resume them. * Additionally, Android 14 introduces a change that prevents non-dismissible notifications. This means users can now dismiss notifications by swiping left or right. Since our app uses a foreground service for downloads, we must always display a notification. To handle this, we have implemented a mechanism that detects when a paused download notification is dismissed. If the download is still in a paused state, the notification will be re-displayed to ensure users can resume their downloads. --- .../downloadManager/DownloadManagerMonitor.kt | 10 +- .../downloadManager/DownloadMonitorService.kt | 21 ++- .../FetchDownloadNotificationManager.kt | 165 +++++++++++++++++- 3 files changed, 190 insertions(+), 6 deletions(-) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerMonitor.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerMonitor.kt index 9d7a149e6..4caaf4385 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerMonitor.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerMonitor.kt @@ -41,7 +41,8 @@ const val DEFAULT_INT_VALUE = -1 class DownloadManagerMonitor @Inject constructor( val fetch: Fetch, val context: Context, - val downloadRoomDao: DownloadRoomDao + val downloadRoomDao: DownloadRoomDao, + private val fetchDownloadNotificationManager: FetchDownloadNotificationManager ) : DownloadMonitor { private val updater = PublishSubject.create<() -> Unit>() private var updaterDisposable: Disposable? = null @@ -122,7 +123,12 @@ class DownloadManagerMonitor @Inject constructor( } private fun update(download: Download) { - updater.onNext { downloadRoomDao.update(download) } + updater.onNext { + downloadRoomDao.update(download) + if (download.isPaused()) { + fetchDownloadNotificationManager.showDownloadPauseNotification(fetch, download) + } + } } private fun delete(download: Download) { diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt index bd1c5ce2c..035e7e20b 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt @@ -116,11 +116,21 @@ class DownloadMonitorService : Service() { it.status == Status.NONE || it.status == Status.ADDED || it.status == Status.QUEUED || - it.status == Status.DOWNLOADING + it.status == Status.DOWNLOADING || + it.isPaused() }?.let { val notificationBuilder = fetchDownloadNotificationManager.getNotificationBuilder(it.id, it.id) - startForeground(it.id, notificationBuilder.build()) + var foreGroundServiceNotification = notificationBuilder.build() + if (it.isPaused()) { + // Clear any pending actions on this notification builder. + notificationBuilder.clearActions() + // If a download is paused that means there is no notification for it, so we have to + // show our custom cancel notification. + foreGroundServiceNotification = + fetchDownloadNotificationManager.getCancelNotification(fetch, it, notificationBuilder) + } + startForeground(it.id, foreGroundServiceNotification) } ?: kotlin.run { stopForegroundServiceForDownloads() // Cancel the last ongoing notification after detaching it from @@ -165,7 +175,7 @@ class DownloadMonitorService : Service() { } override fun onPaused(download: Download) { - update(download, true) + update(download) } override fun onProgress( @@ -213,6 +223,11 @@ class DownloadMonitorService : Service() { downloadRoomDao.downloads().blockingFirst() } } + // If someone pause the Download then post a notification since fetch removes the + // notification for ongoing download when pause so we needs to show our custom notification. + if (download.isPaused()) { + fetchDownloadNotificationManager.showDownloadPauseNotification(fetch, download) + } if (shouldSetForegroundNotification) { setForegroundNotification(download.id) } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/FetchDownloadNotificationManager.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/FetchDownloadNotificationManager.kt index af734d1b2..06edea1e8 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/FetchDownloadNotificationManager.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/FetchDownloadNotificationManager.kt @@ -19,23 +19,51 @@ package org.kiwix.kiwixmobile.core.downloader.downloadManager import android.annotation.SuppressLint +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.app.PendingIntent.FLAG_IMMUTABLE import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.app.PendingIntent.getActivity +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Build import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.Builder +import com.tonyodev.fetch2.ACTION_TYPE_CANCEL +import com.tonyodev.fetch2.ACTION_TYPE_DELETE +import com.tonyodev.fetch2.ACTION_TYPE_INVALID +import com.tonyodev.fetch2.ACTION_TYPE_PAUSE +import com.tonyodev.fetch2.ACTION_TYPE_RESUME +import com.tonyodev.fetch2.ACTION_TYPE_RETRY +import com.tonyodev.fetch2.DOWNLOAD_ID_INVALID import com.tonyodev.fetch2.DefaultFetchNotificationManager +import com.tonyodev.fetch2.Download import com.tonyodev.fetch2.DownloadNotification +import com.tonyodev.fetch2.DownloadNotification.ActionType.CANCEL +import com.tonyodev.fetch2.DownloadNotification.ActionType.DELETE +import com.tonyodev.fetch2.DownloadNotification.ActionType.PAUSE +import com.tonyodev.fetch2.DownloadNotification.ActionType.RESUME +import com.tonyodev.fetch2.DownloadNotification.ActionType.RETRY +import com.tonyodev.fetch2.EXTRA_ACTION_TYPE +import com.tonyodev.fetch2.EXTRA_DOWNLOAD_ID +import com.tonyodev.fetch2.EXTRA_GROUP_ACTION +import com.tonyodev.fetch2.EXTRA_NAMESPACE +import com.tonyodev.fetch2.EXTRA_NOTIFICATION_GROUP_ID +import com.tonyodev.fetch2.EXTRA_NOTIFICATION_ID import com.tonyodev.fetch2.Fetch import com.tonyodev.fetch2.R.drawable import com.tonyodev.fetch2.R.string +import com.tonyodev.fetch2.Status import com.tonyodev.fetch2.util.DEFAULT_NOTIFICATION_TIMEOUT_AFTER_RESET +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.kiwix.kiwixmobile.core.CoreApp import org.kiwix.kiwixmobile.core.Intents import org.kiwix.kiwixmobile.core.R @@ -46,9 +74,35 @@ import javax.inject.Inject const val DOWNLOAD_NOTIFICATION_TITLE = "OPEN_ZIM_FILE" class FetchDownloadNotificationManager @Inject constructor( - context: Context, + val context: Context, private val downloadRoomDao: DownloadRoomDao ) : DefaultFetchNotificationManager(context) { + private val downloadNotificationManager: NotificationManager by lazy { + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + private val notificationDismissAction = "NOTIFICATION_DISMISS_ACTION" + + init { + registerDismissNotificationBroadcastReceiver() + } + + @SuppressLint("UnspecifiedRegisterReceiverFlag") + private fun registerDismissNotificationBroadcastReceiver() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver( + notificationDismissBroadcastReceiver, + IntentFilter(notificationDismissAction), + Context.RECEIVER_EXPORTED + ) + } else { + context.registerReceiver( + notificationDismissBroadcastReceiver, + IntentFilter(notificationDismissAction) + ) + } + } + override fun getFetchInstanceForNamespace(namespace: String): Fetch = Fetch.getDefaultInstance() override fun registerBroadcastReceiver() { @@ -176,4 +230,113 @@ class FetchDownloadNotificationManager @Inject constructor( notificationBuilder.setAutoCancel(true) } } + + fun showDownloadPauseNotification(fetch: Fetch, download: Download) { + CoroutineScope(Dispatchers.IO).launch { + val notificationBuilder = getNotificationBuilder(download.id, download.id) + val cancelNotification = getCancelNotification(fetch, download, notificationBuilder) + downloadNotificationManager.notify(download.id, cancelNotification) + } + } + + fun getCancelNotification( + fetch: Fetch, + download: Download, + notificationBuilder: Builder + ): Notification { + val downloadTitle = getDownloadNotificationTitle(download) + val notificationTitle = + runBlocking(Dispatchers.IO) { + downloadRoomDao.getEntityForFileName(downloadTitle)?.title + ?: downloadTitle + } + return notificationBuilder.setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentTitle(notificationTitle) + .setContentText(context.getString(string.fetch_notification_download_paused)) + // Set the ongoing true so that could not cancel the pause notification. + // However, on Android 14 and above user can cancel the notification by swipe right so we + // can't control that see https://developer.android.com/about/versions/14/behavior-changes-all#non-dismissable-notifications + .setOngoing(true) + .setGroup(download.id.toString()) + .setGroupSummary(false) + .setProgress(HUNDERED, download.progress, false) + .addAction( + drawable.fetch_notification_cancel, + context.getString(R.string.cancel), + getActionPendingIntent(fetch, download, DownloadNotification.ActionType.DELETE) + ) + .addAction( + drawable.fetch_notification_resume, + context.getString(R.string.notification_resume_button_text), + getActionPendingIntent(fetch, download, DownloadNotification.ActionType.RESUME) + ) + // Set the delete intent so that we can know that user dismiss the paused notification. + .setDeleteIntent(getDeletePendingIntent(download, fetch)) + .build() + } + + /** + * This pending intent will use in the "Pause" notifications when a user removes the paused + * notification this pending intent will trigger and will re-enable the paused notification. + * So that user can see that a download is paused and he can perform the necessary actions. + */ + private fun getDeletePendingIntent(download: Download, fetch: Fetch): PendingIntent { + val intent = Intent(notificationDismissAction).apply { + putExtra(EXTRA_DOWNLOAD_ID, download.id) + putExtra(EXTRA_NAMESPACE, fetch.namespace) + } + return PendingIntent.getBroadcast( + context, + download.id, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } + + private val notificationDismissBroadcastReceiver: BroadcastReceiver + get() = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val namespace = intent?.getStringExtra(EXTRA_NAMESPACE) ?: return + val downloadId = intent.getIntExtra(EXTRA_DOWNLOAD_ID, DOWNLOAD_ID_INVALID) + if (namespace.isEmpty() || downloadId == DOWNLOAD_ID_INVALID) return + val fetch = getFetchInstanceForNamespace(namespace) + if (!fetch.isClosed) { + fetch.getDownload(downloadId) { download -> + download?.takeIf(Download::isPaused)?.let { showDownloadPauseNotification(fetch, it) } + } + } + } + } + + private fun getActionPendingIntent( + fetch: Fetch, + download: Download, + actionType: DownloadNotification.ActionType + ): PendingIntent { + val intent = Intent(notificationManagerAction).apply { + putExtra(EXTRA_NAMESPACE, fetch.namespace) + putExtra(EXTRA_DOWNLOAD_ID, download.id) + putExtra(EXTRA_NOTIFICATION_ID, download.id) + putExtra(EXTRA_GROUP_ACTION, false) + putExtra(EXTRA_NOTIFICATION_GROUP_ID, download.id) + } + val action = when (actionType) { + CANCEL -> ACTION_TYPE_CANCEL + DELETE -> ACTION_TYPE_DELETE + RESUME -> ACTION_TYPE_RESUME + PAUSE -> ACTION_TYPE_PAUSE + RETRY -> ACTION_TYPE_RETRY + else -> ACTION_TYPE_INVALID + } + intent.putExtra(EXTRA_ACTION_TYPE, action) + return PendingIntent.getBroadcast( + context, + download.id + action, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } } + +fun Download.isPaused() = status == Status.PAUSED From 6d407fcfa81692ffcf299a0379ee6633cf6f1526 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Wed, 5 Feb 2025 18:35:32 +0530 Subject: [PATCH 2/2] Removed the again showing the notification if user cancels it, and improved the setting of foreground notification when all other downloads are paused. --- .../downloadManager/DownloadMonitorService.kt | 52 +++++++++++----- .../FetchDownloadNotificationManager.kt | 59 ------------------- 2 files changed, 37 insertions(+), 74 deletions(-) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt index 035e7e20b..6254d0269 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt @@ -118,20 +118,7 @@ class DownloadMonitorService : Service() { it.status == Status.QUEUED || it.status == Status.DOWNLOADING || it.isPaused() - }?.let { - val notificationBuilder = - fetchDownloadNotificationManager.getNotificationBuilder(it.id, it.id) - var foreGroundServiceNotification = notificationBuilder.build() - if (it.isPaused()) { - // Clear any pending actions on this notification builder. - notificationBuilder.clearActions() - // If a download is paused that means there is no notification for it, so we have to - // show our custom cancel notification. - foreGroundServiceNotification = - fetchDownloadNotificationManager.getCancelNotification(fetch, it, notificationBuilder) - } - startForeground(it.id, foreGroundServiceNotification) - } ?: kotlin.run { + }?.let(::setForegroundNotificationForDownload) ?: kotlin.run { stopForegroundServiceForDownloads() // Cancel the last ongoing notification after detaching it from // the foreground service if no active downloads are found. @@ -141,6 +128,21 @@ class DownloadMonitorService : Service() { } } + private fun setForegroundNotificationForDownload(it: Download) { + val notificationBuilder = + fetchDownloadNotificationManager.getNotificationBuilder(it.id, it.id) + var foreGroundServiceNotification = notificationBuilder.build() + if (it.isPaused()) { + // Clear any pending actions on this notification builder. + notificationBuilder.clearActions() + // If a download is paused that means there is no notification for it, so we have to + // show our custom cancel notification. + foreGroundServiceNotification = + fetchDownloadNotificationManager.getCancelNotification(fetch, it, notificationBuilder) + } + startForeground(it.id, foreGroundServiceNotification) + } + private fun cancelNotificationForId(downloadId: Int) { notificationManager.cancel(downloadId) } @@ -226,7 +228,9 @@ class DownloadMonitorService : Service() { // If someone pause the Download then post a notification since fetch removes the // notification for ongoing download when pause so we needs to show our custom notification. if (download.isPaused()) { - fetchDownloadNotificationManager.showDownloadPauseNotification(fetch, download) + fetchDownloadNotificationManager.showDownloadPauseNotification(fetch, download).also { + setForeGroundServiceNotificationIfNoActiveDownloads(fetch, download) + } } if (shouldSetForegroundNotification) { setForegroundNotification(download.id) @@ -242,6 +246,24 @@ class DownloadMonitorService : Service() { } } + private fun setForeGroundServiceNotificationIfNoActiveDownloads( + fetch: Fetch, + download: Download + ) { + updater.onNext { + // Check if there are any ongoing downloads. + // If the list is empty, it means no other downloads are running, + // so we need to promote this download to a foreground service. + fetch.getDownloadsWithStatus( + listOf(Status.NONE, Status.ADDED, Status.QUEUED, Status.DOWNLOADING) + ) { activeDownloads -> + if (activeDownloads.isEmpty()) { + setForegroundNotificationForDownload(download) + } + } + } + } + @Suppress("MagicNumber") private fun showDownloadCompletedNotification(download: Download) { downloadNotificationChannel() diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/FetchDownloadNotificationManager.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/FetchDownloadNotificationManager.kt index 06edea1e8..e18abada1 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/FetchDownloadNotificationManager.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/FetchDownloadNotificationManager.kt @@ -26,7 +26,6 @@ import android.app.PendingIntent import android.app.PendingIntent.FLAG_IMMUTABLE import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.app.PendingIntent.getActivity -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -40,7 +39,6 @@ import com.tonyodev.fetch2.ACTION_TYPE_INVALID import com.tonyodev.fetch2.ACTION_TYPE_PAUSE import com.tonyodev.fetch2.ACTION_TYPE_RESUME import com.tonyodev.fetch2.ACTION_TYPE_RETRY -import com.tonyodev.fetch2.DOWNLOAD_ID_INVALID import com.tonyodev.fetch2.DefaultFetchNotificationManager import com.tonyodev.fetch2.Download import com.tonyodev.fetch2.DownloadNotification @@ -81,28 +79,6 @@ class FetchDownloadNotificationManager @Inject constructor( context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } - private val notificationDismissAction = "NOTIFICATION_DISMISS_ACTION" - - init { - registerDismissNotificationBroadcastReceiver() - } - - @SuppressLint("UnspecifiedRegisterReceiverFlag") - private fun registerDismissNotificationBroadcastReceiver() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver( - notificationDismissBroadcastReceiver, - IntentFilter(notificationDismissAction), - Context.RECEIVER_EXPORTED - ) - } else { - context.registerReceiver( - notificationDismissBroadcastReceiver, - IntentFilter(notificationDismissAction) - ) - } - } - override fun getFetchInstanceForNamespace(namespace: String): Fetch = Fetch.getDefaultInstance() override fun registerBroadcastReceiver() { @@ -271,44 +247,9 @@ class FetchDownloadNotificationManager @Inject constructor( context.getString(R.string.notification_resume_button_text), getActionPendingIntent(fetch, download, DownloadNotification.ActionType.RESUME) ) - // Set the delete intent so that we can know that user dismiss the paused notification. - .setDeleteIntent(getDeletePendingIntent(download, fetch)) .build() } - /** - * This pending intent will use in the "Pause" notifications when a user removes the paused - * notification this pending intent will trigger and will re-enable the paused notification. - * So that user can see that a download is paused and he can perform the necessary actions. - */ - private fun getDeletePendingIntent(download: Download, fetch: Fetch): PendingIntent { - val intent = Intent(notificationDismissAction).apply { - putExtra(EXTRA_DOWNLOAD_ID, download.id) - putExtra(EXTRA_NAMESPACE, fetch.namespace) - } - return PendingIntent.getBroadcast( - context, - download.id, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - } - - private val notificationDismissBroadcastReceiver: BroadcastReceiver - get() = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - val namespace = intent?.getStringExtra(EXTRA_NAMESPACE) ?: return - val downloadId = intent.getIntExtra(EXTRA_DOWNLOAD_ID, DOWNLOAD_ID_INVALID) - if (namespace.isEmpty() || downloadId == DOWNLOAD_ID_INVALID) return - val fetch = getFetchInstanceForNamespace(namespace) - if (!fetch.isClosed) { - fetch.getDownload(downloadId) { download -> - download?.takeIf(Download::isPaused)?.let { showDownloadPauseNotification(fetch, it) } - } - } - } - } - private fun getActionPendingIntent( fetch: Fetch, download: Download,