From 0f377ffc50cc3e818625b30940e278030b3cd62d Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Tue, 10 Dec 2024 16:50:09 +0530 Subject: [PATCH 01/12] Fixed: Downloads were not automatically starting or showing progress when reopening the app. * Previously, if a download was stopped due to a network error and the Download Manager was waiting to retry, we were not receiving updates from the Download Manager. As a result, the download progress was not being displayed when the app was reopened. * Improved the service start mechanism: If the application is in the background, we now avoid starting the service because foreground services cannot be started when the application is not in the foreground. --- .../downloadManager/DownloadManagerMonitor.kt | 38 +++++++++++++------ .../downloadManager/DownloadMonitorService.kt | 7 ++++ 2 files changed, 33 insertions(+), 12 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 4d41202155..4b6e685fd2 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 @@ -62,11 +62,15 @@ class DownloadManagerMonitor @Inject constructor( // Download Manager takes some time to update the download status. // In such cases, the foreground service may stop prematurely due to // a lack of active downloads during this update delay. + Log.e( + "DOWNLOAD_MONITOR", + "startMonitoringDownloads: monitor ${shouldStartDownloadMonitorService()}" + ) if (downloadRoomDao.downloads().blockingFirst().isNotEmpty()) { // Check if there are active downloads and the service is not running. // If so, start the DownloadMonitorService to properly track download progress. - if (shouldStartService()) { - startService() + if (shouldStartDownloadMonitorService()) { + startDownloadMonitorService() } else { // Do nothing; it is for fixing the error when "if" is used as an expression. } @@ -89,32 +93,42 @@ class DownloadManagerMonitor @Inject constructor( * Determines if the DownloadMonitorService should be started. * Checks if there are active downloads and if the service is not already running. */ - private fun shouldStartService(): Boolean = + private fun shouldStartDownloadMonitorService(): Boolean = getActiveDownloads().isNotEmpty() && !context.isServiceRunning(DownloadMonitorService::class.java) private fun getActiveDownloads(): List = downloadRoomDao.downloadRoomEntity().blockingFirst().filter { - it.status != Status.PAUSED && it.status != Status.CANCELLED + (it.status != Status.PAUSED || it.error == Error.WAITING_TO_RETRY) && + it.status != Status.CANCELLED } override fun downloadCompleteOrCancelled(intent: Intent) { synchronized(lock) { intent.extras?.let { - val downloadId = it.getLong(DownloadManager.EXTRA_DOWNLOAD_ID, -1L) - if (downloadId != -1L) { - context.startService( - getDownloadMonitorIntent( - ACTION_QUERY_DOWNLOAD_STATUS, - downloadId.toInt() + val downloadId = it.getLong(DownloadManager.EXTRA_DOWNLOAD_ID, DEFAULT_INT_VALUE.toLong()) + if (downloadId != DEFAULT_INT_VALUE.toLong()) { + try { + context.startService( + getDownloadMonitorIntent( + ACTION_QUERY_DOWNLOAD_STATUS, + downloadId.toInt() + ) + ) + } catch (ignore: Exception) { + // Catching exception if application is not in foreground. + // Since we can not start the foreground services from background. + Log.e( + "DOWNLOAD_MONITOR", + "Couldn't start download service. Original exception = $ignore" ) - ) + } } } } } - private fun startService() { + private fun startDownloadMonitorService() { context.startService(Intent(context, DownloadMonitorService::class.java)) } 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 403db07dfb..4789dec5d6 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 @@ -137,6 +137,7 @@ class DownloadMonitorService : Service() { { try { synchronized(lock) { + Log.e("DOWNLOAD_MONITOR", "startMonitoringDownloads: service") if (downloadRoomDao.downloads().blockingFirst().isNotEmpty()) { checkDownloads() } else { @@ -385,6 +386,11 @@ class DownloadMonitorService : Service() { ) { synchronized(lock) { updater.onNext { + Log.e( + "DOWNLOAD_MONITOR", + "updateDownloadStatus: status = $status\n" + + "error = $error\n bytesDownloaded = $bytesDownloaded" + ) downloadRoomDao.getEntityForDownloadId(downloadId)?.let { downloadEntity -> if (shouldUpdateDownloadStatus(downloadEntity)) { val downloadModel = DownloadModel(downloadEntity).apply { @@ -612,6 +618,7 @@ class DownloadMonitorService : Service() { } private fun stopForegroundServiceForDownloads() { + Log.e("DOWNLOAD_MONITOR", "stopForegroundServiceForDownloads: service") foreGroundServiceInformation = true to DEFAULT_INT_VALUE monitoringDisposable?.dispose() stopForeground(STOP_FOREGROUND_REMOVE) From 7863ea1a8acac26dc10dca79c9f2134c98f7f72e Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Tue, 10 Dec 2024 16:54:37 +0530 Subject: [PATCH 02/12] Fixed: `IllegalStateException` while setting the toolbar in `SearchFragment` which i accidentally faced while navigating very frequently to other screens. --- .../org/kiwix/kiwixmobile/core/search/SearchFragment.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt index d3bc7d308c..a028247d2b 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt @@ -192,11 +192,12 @@ class SearchFragment : BaseFragment() { ) } + @Suppress("UnnecessarySafeCall") private fun setupToolbar(view: View) { view.post { - with(requireActivity() as CoreMainActivity) { - setSupportActionBar(view.findViewById(R.id.toolbar)) - supportActionBar?.apply { + with(activity as? CoreMainActivity) { + this?.setSupportActionBar(view.findViewById(R.id.toolbar)) + this?.supportActionBar?.apply { setHomeButtonEnabled(true) title = getString(R.string.menu_search_in_text) } From 10f13f45c3ce73a14316ee25524614c802aee495 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Tue, 10 Dec 2024 17:36:46 +0530 Subject: [PATCH 03/12] Keeping the foreground service active when downloads are paused due to network errors (especially during network fluctuations), as the Download Manager will retry after some time once the connection is restored. --- .../core/downloader/downloadManager/DownloadMonitorService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 4789dec5d6..275fc1717f 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 @@ -502,7 +502,8 @@ class DownloadMonitorService : Service() { private fun getActiveDownloads(): List = downloadRoomDao.downloadRoomEntity().blockingFirst().filter { - it.status != Status.PAUSED && it.status != Status.CANCELLED + (it.status != Status.PAUSED || it.error == Error.WAITING_TO_RETRY) && + it.status != Status.CANCELLED } private fun updateNotification( From d8f389706a383e465771c9d82e3ce0c8aab1ffa5 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Tue, 10 Dec 2024 18:58:42 +0530 Subject: [PATCH 04/12] Improved handling of scenarios where download progress was interrupted due to network errors (e.g., network fluctuations). The application now correctly retrieves download progress from the DownloadManager and, if necessary, automatically resumes paused downloads without requiring user intervention. * Downloads paused due to network errors like "Waiting to Retry" are now resumed automatically when the network becomes available. * For downloads configured to only proceed on Wi-Fi, the application will resume progress when a Wi-Fi connection is re-established. Similarly, downloads queued for mobile networks will resume when the mobile network reconnects. --- .../downloadManager/DownloadManagerMonitor.kt | 54 +++++- .../downloadManager/DownloadMonitorService.kt | 174 +++++++++++++----- 2 files changed, 178 insertions(+), 50 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 4b6e685fd2..b112d4abe7 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 @@ -32,6 +32,7 @@ import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificatio import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager.Companion.ACTION_QUERY_DOWNLOAD_STATUS import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager.Companion.ACTION_RESUME import org.kiwix.kiwixmobile.core.extensions.isServiceRunning +import org.kiwix.kiwixmobile.core.utils.NetworkUtils import org.kiwix.kiwixmobile.core.utils.files.Log import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -62,10 +63,6 @@ class DownloadManagerMonitor @Inject constructor( // Download Manager takes some time to update the download status. // In such cases, the foreground service may stop prematurely due to // a lack of active downloads during this update delay. - Log.e( - "DOWNLOAD_MONITOR", - "startMonitoringDownloads: monitor ${shouldStartDownloadMonitorService()}" - ) if (downloadRoomDao.downloads().blockingFirst().isNotEmpty()) { // Check if there are active downloads and the service is not running. // If so, start the DownloadMonitorService to properly track download progress. @@ -98,10 +95,51 @@ class DownloadManagerMonitor @Inject constructor( !context.isServiceRunning(DownloadMonitorService::class.java) private fun getActiveDownloads(): List = - downloadRoomDao.downloadRoomEntity().blockingFirst().filter { - (it.status != Status.PAUSED || it.error == Error.WAITING_TO_RETRY) && - it.status != Status.CANCELLED - } + downloadRoomDao.downloadRoomEntity().blockingFirst().filter(::isActiveDownload) + + /** + * Determines if a given download is considered active. + * + * @param download The DownloadRoomEntity to evaluate. + * @return True if the download is active, false otherwise. + */ + private fun isActiveDownload(download: DownloadRoomEntity): Boolean = + (download.status != Status.PAUSED || isPausedAndRetryable(download)) && + download.status != Status.CANCELLED + + /** + * Checks if a paused download is eligible for retry based on its error status and network conditions. + * + * @param download The DownloadRoomEntity to evaluate. + * @return True if the paused download is retryable, false otherwise. + */ + private fun isPausedAndRetryable(download: DownloadRoomEntity): Boolean { + return download.status == Status.PAUSED && + ( + isQueuedForWiFiAndConnected(download) || + isQueuedForNetwork(download) || + download.error == Error.WAITING_TO_RETRY + ) && + NetworkUtils.isNetworkAvailable(context) + } + + /** + * Checks if the download is queued for Wi-Fi and the device is connected to Wi-Fi. + * + * @param download The DownloadRoomEntity to evaluate. + * @return True if the download is queued for Wi-Fi and connected, false otherwise. + */ + private fun isQueuedForWiFiAndConnected(download: DownloadRoomEntity): Boolean = + download.error == Error.QUEUED_FOR_WIFI && NetworkUtils.isWiFi(context) + + /** + * Checks if the download is waiting for a network connection and the network is now available. + * + * @param download The DownloadRoomEntity to evaluate. + * @return True if the download is waiting for a network and connected, false otherwise. + */ + private fun isQueuedForNetwork(download: DownloadRoomEntity): Boolean = + download.error == Error.WAITING_FOR_NETWORK && NetworkUtils.isNetworkAvailable(context) override fun downloadCompleteOrCancelled(intent: Intent) { synchronized(lock) { 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 275fc1717f..1464c0e471 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 @@ -40,6 +40,7 @@ import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificatio import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager.Companion.ACTION_RESUME import org.kiwix.kiwixmobile.core.downloader.model.DownloadModel import org.kiwix.kiwixmobile.core.downloader.model.DownloadState +import org.kiwix.kiwixmobile.core.utils.NetworkUtils import org.kiwix.kiwixmobile.core.utils.files.Log import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -137,7 +138,6 @@ class DownloadMonitorService : Service() { { try { synchronized(lock) { - Log.e("DOWNLOAD_MONITOR", "startMonitoringDownloads: service") if (downloadRoomDao.downloads().blockingFirst().isNotEmpty()) { checkDownloads() } else { @@ -386,11 +386,6 @@ class DownloadMonitorService : Service() { ) { synchronized(lock) { updater.onNext { - Log.e( - "DOWNLOAD_MONITOR", - "updateDownloadStatus: status = $status\n" + - "error = $error\n bytesDownloaded = $bytesDownloaded" - ) downloadRoomDao.getEntityForDownloadId(downloadId)?.let { downloadEntity -> if (shouldUpdateDownloadStatus(downloadEntity)) { val downloadModel = DownloadModel(downloadEntity).apply { @@ -426,10 +421,9 @@ class DownloadMonitorService : Service() { /** * Determines whether the download status should be updated based on the current status and error. * - * This method checks the current download status and error, and decides whether to update the status - * of the download entity. Specifically, it handles the case where a download is paused but has been - * queued for resumption. In such cases, it ensures that the download manager is instructed to resume - * the download, and prevents the status from being prematurely updated to "Paused". + * This method evaluates the current download status and error conditions, ensuring proper handling + * for paused downloads, queued downloads, and network-related retries. It coordinates with the + * Download Manager to resume downloads when necessary and prevents premature status updates. * * @param status The current status of the download. * @param error The current error state of the download. @@ -442,38 +436,94 @@ class DownloadMonitorService : Service() { downloadRoomEntity: DownloadRoomEntity ): Boolean { synchronized(lock) { - return@shouldUpdateDownloadStatus if ( - status == Status.PAUSED && - downloadRoomEntity.status == Status.QUEUED - ) { - // Check if the user has resumed the download. - // Do not update the download status immediately since the download manager - // takes some time to actually resume the download. During this time, - // it will still return the paused state. - // By not updating the status right away, we ensure that the user - // sees the "Pending" state, indicating that the download is in the process - // of resuming. - when (error) { - // When the pause reason is unknown or waiting to retry, and the user - // resumes the download, attempt to resume the download if it was not resumed - // due to some reason. - Error.PAUSED_UNKNOWN, - Error.WAITING_TO_RETRY -> { - resumeDownload(downloadRoomEntity.downloadId) - false - } + return@shouldUpdateDownloadStatus when { + // Check if the download is paused and was previously queued. + isPausedAndQueued(status, downloadRoomEntity) -> + handlePausedAndQueuedDownload(error, downloadRoomEntity) - // Return true to update the status of the download if there is any other status, - // e.g., WAITING_FOR_WIFI, WAITING_FOR_NETWORK, or any other pause reason - // to inform the user. - else -> true - } - } else { - true + // Check if the download is paused and retryable due to network availability. + isPausedAndRetryable(status, error) -> handleRetryablePausedDownload(downloadRoomEntity) + + // Default case: update the status. + else -> true + } + } + } + + /** + * Checks if the download is paused and was previously queued. + * + * Specifically, it evaluates whether the current status is "Paused" while the previous status + * was "Queued", indicating that the user might have initiated a resume action. + * + * @param status The current status of the download. + * @param downloadRoomEntity The download entity to evaluate. + * @return `true` if the download is paused and queued, `false` otherwise. + */ + private fun isPausedAndQueued(status: Status, downloadRoomEntity: DownloadRoomEntity): Boolean = + status == Status.PAUSED && downloadRoomEntity.status == Status.QUEUED + + /** + * Checks if the download is paused and retryable based on the error and network conditions. + * + * This evaluates whether the download can be resumed, considering its paused state, + * error condition (e.g., waiting for retry), and the availability of a network connection. + * + * @param status The current status of the download. + * @param error The current error state of the download. + * @return `true` if the download is paused and retryable, `false` otherwise. + */ + private fun isPausedAndRetryable(status: Status, error: Error): Boolean { + return status == Status.PAUSED && + error == Error.WAITING_TO_RETRY && + NetworkUtils.isNetworkAvailable(this) + } + + /** + * Handles the case where a paused download was previously queued. + * + * This ensures that the download manager is instructed to resume the download and prevents + * the status from being prematurely updated to "Paused". Instead, the user will see the "Pending" + * state, indicating that the download is in the process of resuming. + * + * @param error The current error state of the download. + * @param downloadRoomEntity The download entity to evaluate. + * @return `true` if the status should be updated, `false` otherwise. + */ + private fun handlePausedAndQueuedDownload( + error: Error, + downloadRoomEntity: DownloadRoomEntity + ): Boolean { + return when (error) { + // When the pause reason is unknown or waiting to retry, and the user + // resumes the download, attempt to resume the download if it was not resumed + // due to some reason. + Error.PAUSED_UNKNOWN, + Error.WAITING_TO_RETRY -> { + resumeDownload(downloadRoomEntity.downloadId) + false } + + // For any other error state, update the status to reflect the current state + // and provide feedback to the user. + else -> true } } + /** + * Handles the case where a paused download is retryable due to network availability. + * + * If the download manager is waiting to retry due to a network error caused by fluctuations, + * this method resumes the download and ensures the status reflects the resumption process. + * + * @param downloadRoomEntity The download entity to evaluate. + * @return `true` to update the status and attempt to resume the download. + */ + private fun handleRetryablePausedDownload(downloadRoomEntity: DownloadRoomEntity): Boolean { + resumeDownload(downloadRoomEntity.downloadId) + return true + } + private fun cancelNotificationAndAssignNewNotificationToForegroundService(downloadId: Long) { downloadNotificationManager.cancelNotification(downloadId.toInt()) updateForegroundNotificationOrStopService() @@ -501,10 +551,51 @@ class DownloadMonitorService : Service() { } private fun getActiveDownloads(): List = - downloadRoomDao.downloadRoomEntity().blockingFirst().filter { - (it.status != Status.PAUSED || it.error == Error.WAITING_TO_RETRY) && - it.status != Status.CANCELLED - } + downloadRoomDao.downloadRoomEntity().blockingFirst().filter(::isActiveDownload) + + /** + * Determines if a given download is considered active. + * + * @param download The DownloadRoomEntity to evaluate. + * @return True if the download is active, false otherwise. + */ + private fun isActiveDownload(download: DownloadRoomEntity): Boolean = + (download.status != Status.PAUSED || isPausedAndRetryable(download)) && + download.status != Status.CANCELLED + + /** + * Checks if a paused download is eligible for retry based on its error status and network conditions. + * + * @param download The DownloadRoomEntity to evaluate. + * @return True if the paused download is retryable, false otherwise. + */ + private fun isPausedAndRetryable(download: DownloadRoomEntity): Boolean { + return download.status == Status.PAUSED && + ( + isQueuedForWiFiAndConnected(download) || + isQueuedForNetwork(download) || + download.error == Error.WAITING_TO_RETRY + ) && + NetworkUtils.isNetworkAvailable(this) + } + + /** + * Checks if the download is queued for Wi-Fi and the device is connected to Wi-Fi. + * + * @param download The DownloadRoomEntity to evaluate. + * @return True if the download is queued for Wi-Fi and connected, false otherwise. + */ + private fun isQueuedForWiFiAndConnected(download: DownloadRoomEntity): Boolean = + download.error == Error.QUEUED_FOR_WIFI && NetworkUtils.isWiFi(this) + + /** + * Checks if the download is waiting for a network connection and the network is now available. + * + * @param download The DownloadRoomEntity to evaluate. + * @return True if the download is waiting for a network and connected, false otherwise. + */ + private fun isQueuedForNetwork(download: DownloadRoomEntity): Boolean = + download.error == Error.WAITING_FOR_NETWORK && NetworkUtils.isNetworkAvailable(this) private fun updateNotification( downloadModel: DownloadModel, @@ -619,7 +710,6 @@ class DownloadMonitorService : Service() { } private fun stopForegroundServiceForDownloads() { - Log.e("DOWNLOAD_MONITOR", "stopForegroundServiceForDownloads: service") foreGroundServiceInformation = true to DEFAULT_INT_VALUE monitoringDisposable?.dispose() stopForeground(STOP_FOREGROUND_REMOVE) From 0dc403fe3db17dc09662e61c3a0ea6b70a1a5391 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Thu, 12 Dec 2024 15:37:45 +0530 Subject: [PATCH 05/12] Improved the download functionality when the user pauses the download. * After implementing the previous approach to track download progress during network fluctuations, a new issue occurred. Sometimes, when the user pauses a download, it resumes immediately without any user interaction. To address this, we introduced a new field to track the paused status, distinguishing whether the pause was initiated by the user or caused by the DownloadManager due to network fluctuations. --- .../core/dao/entities/DownloadRoomEntity.kt | 3 +- .../core/data/KiwixRoomDatabase.kt | 19 +++++++++-- .../downloadManager/DownloadManagerMonitor.kt | 5 +-- .../downloadManager/DownloadMonitorService.kt | 32 ++++++++++++++----- .../core/downloader/model/DownloadModel.kt | 6 ++-- 5 files changed, 50 insertions(+), 15 deletions(-) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/dao/entities/DownloadRoomEntity.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/dao/entities/DownloadRoomEntity.kt index 1a049bae46..9ce3e93ea4 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/dao/entities/DownloadRoomEntity.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/dao/entities/DownloadRoomEntity.kt @@ -54,7 +54,8 @@ data class DownloadRoomEntity( val size: String, val name: String?, val favIcon: String, - val tags: String? = null + val tags: String? = null, + var pausedByUser: Boolean = false ) { constructor(downloadUrl: String, downloadId: Long, book: Book, file: String?) : this( file = file, diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/data/KiwixRoomDatabase.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/data/KiwixRoomDatabase.kt index 58a89a6c70..a5561b5786 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/data/KiwixRoomDatabase.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/data/KiwixRoomDatabase.kt @@ -44,7 +44,7 @@ import org.kiwix.kiwixmobile.core.dao.entities.ZimSourceRoomConverter NotesRoomEntity::class, DownloadRoomEntity::class ], - version = 5, + version = 6, exportSchema = false ) @TypeConverters(HistoryRoomDaoCoverts::class, ZimSourceRoomConverter::class) @@ -62,7 +62,13 @@ abstract class KiwixRoomDatabase : RoomDatabase() { ?: Room.databaseBuilder(context, KiwixRoomDatabase::class.java, "KiwixRoom.db") // We have already database name called kiwix.db in order to avoid complexity we named // as kiwixRoom.db - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) + .addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6 + ) .build().also { db = it } } } @@ -202,6 +208,15 @@ abstract class KiwixRoomDatabase : RoomDatabase() { } } + @Suppress("MagicNumber") + private val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "ALTER TABLE DownloadRoomEntity ADD COLUMN pausedByUser INTEGER NOT NULL DEFAULT 0" + ) + } + } + fun destroyInstance() { db = null } 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 b112d4abe7..787c75c40a 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 @@ -118,9 +118,10 @@ class DownloadManagerMonitor @Inject constructor( ( isQueuedForWiFiAndConnected(download) || isQueuedForNetwork(download) || - download.error == Error.WAITING_TO_RETRY + (download.error == Error.WAITING_TO_RETRY || download.error == Error.PAUSED_UNKNOWN) ) && - NetworkUtils.isNetworkAvailable(context) + NetworkUtils.isNetworkAvailable(context) && + !download.pausedByUser } /** 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 1464c0e471..f539ed46b5 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 @@ -382,13 +382,18 @@ class DownloadMonitorService : Service() { progress: Int = DEFAULT_INT_VALUE, etaInMilliSeconds: Long = DEFAULT_INT_VALUE.toLong(), bytesDownloaded: Long = DEFAULT_INT_VALUE.toLong(), - totalSizeOfDownload: Long = DEFAULT_INT_VALUE.toLong() + totalSizeOfDownload: Long = DEFAULT_INT_VALUE.toLong(), + pausedByUser: Boolean? = null ) { synchronized(lock) { updater.onNext { downloadRoomDao.getEntityForDownloadId(downloadId)?.let { downloadEntity -> if (shouldUpdateDownloadStatus(downloadEntity)) { val downloadModel = DownloadModel(downloadEntity).apply { + pausedByUser?.let { + this.pausedByUser = it + downloadEntity.pausedByUser = it + } if (shouldUpdateDownloadStatus(status, error, downloadEntity)) { state = status } @@ -442,7 +447,13 @@ class DownloadMonitorService : Service() { handlePausedAndQueuedDownload(error, downloadRoomEntity) // Check if the download is paused and retryable due to network availability. - isPausedAndRetryable(status, error) -> handleRetryablePausedDownload(downloadRoomEntity) + isPausedAndRetryable( + status, + error, + downloadRoomEntity.pausedByUser + ) -> { + handleRetryablePausedDownload(downloadRoomEntity) + } // Default case: update the status. else -> true @@ -471,12 +482,14 @@ class DownloadMonitorService : Service() { * * @param status The current status of the download. * @param error The current error state of the download. + * @param pausedByUser To identify if the download paused by user or downloadManager. * @return `true` if the download is paused and retryable, `false` otherwise. */ - private fun isPausedAndRetryable(status: Status, error: Error): Boolean { + private fun isPausedAndRetryable(status: Status, error: Error, pausedByUser: Boolean): Boolean { return status == Status.PAUSED && - error == Error.WAITING_TO_RETRY && - NetworkUtils.isNetworkAvailable(this) + (error == Error.WAITING_TO_RETRY || error == Error.PAUSED_UNKNOWN) && + NetworkUtils.isNetworkAvailable(this) && + !pausedByUser } /** @@ -576,7 +589,8 @@ class DownloadMonitorService : Service() { isQueuedForNetwork(download) || download.error == Error.WAITING_TO_RETRY ) && - NetworkUtils.isNetworkAvailable(this) + NetworkUtils.isNetworkAvailable(this) && + !download.pausedByUser } /** @@ -648,7 +662,8 @@ class DownloadMonitorService : Service() { STATUS_PAUSED_BY_APP ) ) { - updateDownloadStatus(downloadId, Status.PAUSED, Error.NONE) + // pass true when user paused the download to not retry the download automatically. + updateDownloadStatus(downloadId, Status.PAUSED, Error.NONE, pausedByUser = true) } } } @@ -663,7 +678,8 @@ class DownloadMonitorService : Service() { STATUS_RUNNING ) ) { - updateDownloadStatus(downloadId, Status.QUEUED, Error.NONE) + // pass false when user resumed the download to proceed with further checks. + updateDownloadStatus(downloadId, Status.QUEUED, Error.NONE, pausedByUser = false) } } } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadModel.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadModel.kt index e76caf8847..d9db90c596 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadModel.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadModel.kt @@ -33,7 +33,8 @@ data class DownloadModel( var state: Status, var error: Error, var progress: Int, - val book: Book + val book: Book, + var pausedByUser: Boolean ) { val bytesRemaining: Long by lazy { totalSizeOfDownload - bytesDownloaded } val fileNameFromUrl: String by lazy { StorageUtils.getFileNameFromUrl(book.url) } @@ -48,6 +49,7 @@ data class DownloadModel( downloadEntity.status, downloadEntity.error, downloadEntity.progress, - downloadEntity.toBook() + downloadEntity.toBook(), + downloadEntity.pausedByUser ) } From c72d32ee60d63df5f8f10719ce83808183d019cc Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Thu, 12 Dec 2024 15:50:14 +0530 Subject: [PATCH 06/12] Fixed: The Resume button was functional while downloading ZIM files, even when they were paused due to no internet connection. We have added a check before pausing or resuming the download. If there is no internet connection, we now display the same "No Internet connection" snackbar message that is shown when attempting to download a book without an internet connection. --- .../nav/destination/library/OnlineLibraryFragment.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/OnlineLibraryFragment.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/OnlineLibraryFragment.kt index 9999f9ca47..732a77be89 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/OnlineLibraryFragment.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/library/OnlineLibraryFragment.kt @@ -148,6 +148,10 @@ class OnlineLibraryFragment : BaseFragment(), FragmentActivityExtensions { }, { context?.let { context -> + if (isNotConnected) { + noInternetSnackbar() + return@let + } downloader.pauseResumeDownload( it.downloadId, it.downloadState.toReadableState(context).contains(getString(string.paused_state)) From 2556f16cab50ecaf75ae1800a425b66d25f8a3c6 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Thu, 12 Dec 2024 16:41:47 +0530 Subject: [PATCH 07/12] Fixed: The compilation errors in test case. --- .../kiwix/kiwixmobile/core/dao/entities/DownloadRoomEntity.kt | 3 ++- .../java/org/kiwix/sharedFunctions/TestModelFunctions.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/dao/entities/DownloadRoomEntity.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/dao/entities/DownloadRoomEntity.kt index 9ce3e93ea4..dff17d8740 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/dao/entities/DownloadRoomEntity.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/dao/entities/DownloadRoomEntity.kt @@ -100,7 +100,8 @@ data class DownloadRoomEntity( totalSizeOfDownload = download.totalSizeOfDownload, status = download.state, error = download.error, - progress = download.progress + progress = download.progress, + pausedByUser = download.pausedByUser ) } diff --git a/core/src/sharedTestFunctions/java/org/kiwix/sharedFunctions/TestModelFunctions.kt b/core/src/sharedTestFunctions/java/org/kiwix/sharedFunctions/TestModelFunctions.kt index a8fae21a4b..e6955b2757 100644 --- a/core/src/sharedTestFunctions/java/org/kiwix/sharedFunctions/TestModelFunctions.kt +++ b/core/src/sharedTestFunctions/java/org/kiwix/sharedFunctions/TestModelFunctions.kt @@ -58,7 +58,7 @@ fun downloadModel( book: Book = book() ) = DownloadModel( databaseId, downloadId, file, etaInMilliSeconds, bytesDownloaded, totalSizeOfDownload, - status, error, progress, book + status, error, progress, book, false ) fun downloadItem( From b83ecde7e01d4c1504abd1d2cdd9dcabc3f57f5c Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Thu, 12 Dec 2024 16:47:06 +0530 Subject: [PATCH 08/12] Fixed: detekt issue. --- .../core/downloader/downloadManager/DownloadManagerMonitor.kt | 3 ++- .../core/downloader/downloadManager/DownloadMonitorService.kt | 3 ++- 2 files changed, 4 insertions(+), 2 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 787c75c40a..fc633d2b2e 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 @@ -118,7 +118,8 @@ class DownloadManagerMonitor @Inject constructor( ( isQueuedForWiFiAndConnected(download) || isQueuedForNetwork(download) || - (download.error == Error.WAITING_TO_RETRY || download.error == Error.PAUSED_UNKNOWN) + download.error == Error.WAITING_TO_RETRY || + download.error == Error.PAUSED_UNKNOWN ) && NetworkUtils.isNetworkAvailable(context) && !download.pausedByUser 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 f539ed46b5..19f76b8711 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 @@ -587,7 +587,8 @@ class DownloadMonitorService : Service() { ( isQueuedForWiFiAndConnected(download) || isQueuedForNetwork(download) || - download.error == Error.WAITING_TO_RETRY + download.error == Error.WAITING_TO_RETRY || + download.error == Error.PAUSED_UNKNOWN ) && NetworkUtils.isNetworkAvailable(this) && !download.pausedByUser From 352606c366f9d140f76ac45b02b8f529a57942f8 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Mon, 16 Dec 2024 14:56:15 +0530 Subject: [PATCH 09/12] Enabled the DownloadManager's notification. * Removed the foreground service from application since now download manager handles the notification so we don't need the foreground service. * Removed the all code related to notification management. --- app/src/main/AndroidManifest.xml | 3 + core/src/main/AndroidManifest.xml | 17 +- .../di/components/CoreServiceComponent.kt | 2 - .../core/di/modules/DownloaderModule.kt | 10 - .../downloadManager/DownloadManagerMonitor.kt | 559 ++++++++++--- .../DownloadManagerRequester.kt | 6 +- .../downloadManager/DownloadMonitorService.kt | 744 ------------------ .../DownloadNotificationManager.kt | 259 ------ .../DownloadNotificationModel.kt | 46 -- 9 files changed, 482 insertions(+), 1164 deletions(-) delete mode 100644 core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt delete mode 100644 core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationManager.kt delete mode 100644 core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 07fe617eaa..34244d904e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,9 @@ android:name="android.permission.NEARBY_WIFI_DEVICES" android:usesPermissionFlags="neverForLocation" tools:targetApi="s" /> + + + - - - - - + + @@ -52,8 +48,8 @@ + tools:overrideLibrary="com.squareup.picasso.picasso"> @@ -92,8 +88,5 @@ android:name=".error.DiagnosticReportActivity" android:exported="false" /> - diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/di/components/CoreServiceComponent.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/di/components/CoreServiceComponent.kt index 27e559a844..bec1c4c337 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/di/components/CoreServiceComponent.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/di/components/CoreServiceComponent.kt @@ -23,14 +23,12 @@ import dagger.BindsInstance import dagger.Subcomponent import org.kiwix.kiwixmobile.core.di.CoreServiceScope import org.kiwix.kiwixmobile.core.di.modules.CoreServiceModule -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadMonitorService import org.kiwix.kiwixmobile.core.read_aloud.ReadAloudService @Subcomponent(modules = [CoreServiceModule::class]) @CoreServiceScope interface CoreServiceComponent { fun inject(readAloudService: ReadAloudService) - fun inject(downloadMonitorService: DownloadMonitorService) @Subcomponent.Builder interface Builder { diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/di/modules/DownloaderModule.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/di/modules/DownloaderModule.kt index f85bf4e09e..a599aa4363 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/di/modules/DownloaderModule.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/di/modules/DownloaderModule.kt @@ -18,8 +18,6 @@ package org.kiwix.kiwixmobile.core.di.modules import android.app.DownloadManager -import android.app.NotificationManager -import android.content.Context import dagger.Module import dagger.Provides import org.kiwix.kiwixmobile.core.dao.DownloadRoomDao @@ -30,7 +28,6 @@ import org.kiwix.kiwixmobile.core.downloader.DownloaderImpl import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadManagerBroadcastReceiver import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadManagerMonitor import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadManagerRequester -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil import javax.inject.Singleton @@ -69,11 +66,4 @@ object DownloaderModule { fun providesDownloadManagerBroadcastReceiver( callback: DownloadManagerBroadcastReceiver.Callback ): DownloadManagerBroadcastReceiver = DownloadManagerBroadcastReceiver(callback) - - @Provides - @Singleton - fun providesDownloadNotificationManager( - context: Context, - notificationManager: NotificationManager - ): DownloadNotificationManager = DownloadNotificationManager(context, notificationManager) } 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 fc633d2b2e..8e0964c1fe 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 @@ -18,38 +18,90 @@ package org.kiwix.kiwixmobile.core.downloader.downloadManager +import android.annotation.SuppressLint import android.app.DownloadManager +import android.content.ContentUris +import android.content.ContentValues import android.content.Context import android.content.Intent +import android.database.Cursor +import android.net.Uri import io.reactivex.Observable import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers +import io.reactivex.subjects.PublishSubject import org.kiwix.kiwixmobile.core.dao.DownloadRoomDao import org.kiwix.kiwixmobile.core.dao.entities.DownloadRoomEntity import org.kiwix.kiwixmobile.core.downloader.DownloadMonitor -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager.Companion.ACTION_CANCEL -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager.Companion.ACTION_PAUSE -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager.Companion.ACTION_QUERY_DOWNLOAD_STATUS -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager.Companion.ACTION_RESUME -import org.kiwix.kiwixmobile.core.extensions.isServiceRunning +import org.kiwix.kiwixmobile.core.downloader.model.DownloadModel import org.kiwix.kiwixmobile.core.utils.NetworkUtils import org.kiwix.kiwixmobile.core.utils.files.Log import java.util.concurrent.TimeUnit import javax.inject.Inject +const val ZERO = 0 +const val HUNDERED = 100 +const val THOUSAND = 1000 +const val DEFAULT_INT_VALUE = -1 + +/* + These below values of android.provider.Downloads.Impl class, + there is no direct way to access them so we defining the values + from https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/core/java/android/provider/Downloads.java + */ +const val CONTROL_PAUSE = 1 +const val CONTROL_RUN = 0 +const val STATUS_RUNNING = 192 +const val STATUS_PAUSED_BY_APP = 193 +const val COLUMN_CONTROL = "control" +val downloadBaseUri: Uri = Uri.parse("content://downloads/my_downloads") +const val DOWNLOAD_NOTIFICATION_TITLE = "OPEN_ZIM_FILE" + class DownloadManagerMonitor @Inject constructor( + private var downloadManager: DownloadManager, val downloadRoomDao: DownloadRoomDao, private val context: Context ) : DownloadMonitor, DownloadManagerBroadcastReceiver.Callback { private val lock = Any() private var monitoringDisposable: Disposable? = null + private val downloadInfoMap = mutableMapOf() + private val updater = PublishSubject.create<() -> Unit>() init { + setupUpdater() startMonitoringDownloads() } + override fun downloadCompleteOrCancelled(intent: Intent) { + synchronized(lock) { + intent.extras?.let { + val downloadId = it.getLong(DownloadManager.EXTRA_DOWNLOAD_ID, DEFAULT_INT_VALUE.toLong()) + if (downloadId != DEFAULT_INT_VALUE.toLong()) { + queryDownloadStatus(downloadId) + } + } + } + } + + @Suppress("CheckResult") + private fun setupUpdater() { + updater.subscribeOn(Schedulers.io()).observeOn(Schedulers.io()).subscribe( + { + synchronized(lock) { it.invoke() } + }, + Throwable::printStackTrace + ) + } + + /** + * Starts monitoring ongoing downloads using a periodic observable. + * This method sets up an observable that runs every 5 seconds to check the status of downloads. + * It only starts the monitoring process if it's not already running and disposes of the observable + * when there are no ongoing downloads to avoid unnecessary resource usage. + */ @Suppress("MagicNumber") fun startMonitoringDownloads() { + // Check if monitoring is already active. If it is, do nothing. if (monitoringDisposable?.isDisposed == false) return monitoringDisposable = Observable.interval(ZERO.toLong(), 5, TimeUnit.SECONDS) .subscribeOn(Schedulers.io()) @@ -58,19 +110,8 @@ class DownloadManagerMonitor @Inject constructor( { try { synchronized(lock) { - // Observe downloads when the application is in the foreground. - // This is especially useful when downloads are resumed but the - // Download Manager takes some time to update the download status. - // In such cases, the foreground service may stop prematurely due to - // a lack of active downloads during this update delay. if (downloadRoomDao.downloads().blockingFirst().isNotEmpty()) { - // Check if there are active downloads and the service is not running. - // If so, start the DownloadMonitorService to properly track download progress. - if (shouldStartDownloadMonitorService()) { - startDownloadMonitorService() - } else { - // Do nothing; it is for fixing the error when "if" is used as an expression. - } + checkDownloads() } else { monitoringDisposable?.dispose() } @@ -86,114 +127,456 @@ class DownloadManagerMonitor @Inject constructor( ) } + @SuppressLint("Range") + private fun checkDownloads() { + synchronized(lock) { + val query = DownloadManager.Query().setFilterByStatus( + DownloadManager.STATUS_RUNNING or + DownloadManager.STATUS_PAUSED or + DownloadManager.STATUS_PENDING or + DownloadManager.STATUS_SUCCESSFUL + ) + downloadManager.query(query).use { cursor -> + if (cursor.moveToFirst()) { + do { + val downloadId = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_ID)) + queryDownloadStatus(downloadId) + } while (cursor.moveToNext()) + } + } + } + } + + @SuppressLint("Range") + fun queryDownloadStatus(downloadId: Long) { + synchronized(lock) { + downloadManager.query(DownloadManager.Query().setFilterById(downloadId)).use { cursor -> + if (cursor.moveToFirst()) { + handleDownloadStatus(cursor, downloadId) + } else { + handleCancelledDownload(downloadId) + } + } + } + } + + @SuppressLint("Range") + private fun handleDownloadStatus(cursor: Cursor, downloadId: Long) { + val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) + val reason = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON)) + val bytesDownloaded = + cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) + val totalBytes = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) + val progress = calculateProgress(bytesDownloaded, totalBytes) + + val etaInMilliSeconds = calculateETA(downloadId, bytesDownloaded, totalBytes) + + when (status) { + DownloadManager.STATUS_FAILED -> handleFailedDownload( + downloadId, + reason, + progress, + etaInMilliSeconds, + bytesDownloaded, + totalBytes + ) + + DownloadManager.STATUS_PAUSED -> handlePausedDownload( + downloadId, + progress, + bytesDownloaded, + totalBytes, + reason + ) + + DownloadManager.STATUS_PENDING -> handlePendingDownload(downloadId) + DownloadManager.STATUS_RUNNING -> handleRunningDownload( + downloadId, + progress, + etaInMilliSeconds, + bytesDownloaded, + totalBytes + ) + + DownloadManager.STATUS_SUCCESSFUL -> handleSuccessfulDownload( + downloadId, + progress, + etaInMilliSeconds + ) + } + } + + private fun handleCancelledDownload(downloadId: Long) { + updater.onNext { + updateDownloadStatus(downloadId, Status.CANCELLED, Error.CANCELLED) + downloadRoomDao.delete(downloadId) + downloadInfoMap.remove(downloadId) + } + } + + @Suppress("LongParameterList") + private fun handleFailedDownload( + downloadId: Long, + reason: Int, + progress: Int, + etaInMilliSeconds: Long, + bytesDownloaded: Long, + totalBytes: Long + ) { + val error = mapDownloadError(reason) + updateDownloadStatus( + downloadId, + Status.FAILED, + error, + progress, + etaInMilliSeconds, + bytesDownloaded, + totalBytes + ) + } + + private fun handlePausedDownload( + downloadId: Long, + progress: Int, + bytesDownloaded: Long, + totalSizeOfDownload: Long, + reason: Int + ) { + val pauseReason = mapDownloadPauseReason(reason) + updateDownloadStatus( + downloadId = downloadId, + status = Status.PAUSED, + error = pauseReason, + progress = progress, + bytesDownloaded = bytesDownloaded, + totalSizeOfDownload = totalSizeOfDownload + ) + } + + private fun handlePendingDownload(downloadId: Long) { + updateDownloadStatus( + downloadId, + Status.QUEUED, + Error.NONE + ) + } + + private fun handleRunningDownload( + downloadId: Long, + progress: Int, + etaInMilliSeconds: Long, + bytesDownloaded: Long, + totalSizeOfDownload: Long + ) { + updateDownloadStatus( + downloadId, + Status.DOWNLOADING, + Error.NONE, + progress, + etaInMilliSeconds, + bytesDownloaded, + totalSizeOfDownload + ) + } + + private fun handleSuccessfulDownload( + downloadId: Long, + progress: Int, + etaInMilliSeconds: Long + ) { + updateDownloadStatus( + downloadId, + Status.COMPLETED, + Error.NONE, + progress, + etaInMilliSeconds + ) + downloadInfoMap.remove(downloadId) + } + + private fun calculateProgress(bytesDownloaded: Long, totalBytes: Long): Int = + if (totalBytes > ZERO) { + (bytesDownloaded / totalBytes.toDouble()).times(HUNDERED).toInt() + } else { + ZERO + } + + private fun calculateETA(downloadedFileId: Long, bytesDownloaded: Long, totalBytes: Long): Long { + val currentTime = System.currentTimeMillis() + val downloadInfo = downloadInfoMap.getOrPut(downloadedFileId) { + DownloadInfo(startTime = currentTime, initialBytesDownloaded = bytesDownloaded) + } + + val elapsedTime = currentTime - downloadInfo.startTime + val downloadSpeed = if (elapsedTime > ZERO) { + (bytesDownloaded - downloadInfo.initialBytesDownloaded) / (elapsedTime / THOUSAND.toFloat()) + } else { + ZERO.toFloat() + } + + return if (downloadSpeed > ZERO) { + ((totalBytes - bytesDownloaded) / downloadSpeed).toLong() * THOUSAND + } else { + ZERO.toLong() + } + } + + private fun mapDownloadError(reason: Int): Error { + return when (reason) { + DownloadManager.ERROR_CANNOT_RESUME -> Error.ERROR_CANNOT_RESUME + DownloadManager.ERROR_DEVICE_NOT_FOUND -> Error.ERROR_DEVICE_NOT_FOUND + DownloadManager.ERROR_FILE_ALREADY_EXISTS -> Error.ERROR_FILE_ALREADY_EXISTS + DownloadManager.ERROR_FILE_ERROR -> Error.ERROR_FILE_ERROR + DownloadManager.ERROR_HTTP_DATA_ERROR -> Error.ERROR_HTTP_DATA_ERROR + DownloadManager.ERROR_INSUFFICIENT_SPACE -> Error.ERROR_INSUFFICIENT_SPACE + DownloadManager.ERROR_TOO_MANY_REDIRECTS -> Error.ERROR_TOO_MANY_REDIRECTS + DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> Error.ERROR_UNHANDLED_HTTP_CODE + DownloadManager.ERROR_UNKNOWN -> Error.UNKNOWN + else -> Error.UNKNOWN + } + } + + private fun mapDownloadPauseReason(reason: Int): Error { + return when (reason) { + DownloadManager.PAUSED_QUEUED_FOR_WIFI -> Error.QUEUED_FOR_WIFI + DownloadManager.PAUSED_WAITING_TO_RETRY -> Error.WAITING_TO_RETRY + DownloadManager.PAUSED_WAITING_FOR_NETWORK -> Error.WAITING_FOR_NETWORK + DownloadManager.PAUSED_UNKNOWN -> Error.PAUSED_UNKNOWN + else -> Error.PAUSED_UNKNOWN + } + } + + @Suppress("LongParameterList") + private fun updateDownloadStatus( + downloadId: Long, + status: Status, + error: Error, + progress: Int = DEFAULT_INT_VALUE, + etaInMilliSeconds: Long = DEFAULT_INT_VALUE.toLong(), + bytesDownloaded: Long = DEFAULT_INT_VALUE.toLong(), + totalSizeOfDownload: Long = DEFAULT_INT_VALUE.toLong(), + pausedByUser: Boolean? = null + ) { + synchronized(lock) { + updater.onNext { + Log.e("DOWNLOAD_MONITOR", "updateDownloadStatus: $status \n $error \n $progress") + downloadRoomDao.getEntityForDownloadId(downloadId)?.let { downloadEntity -> + if (shouldUpdateDownloadStatus(downloadEntity)) { + val downloadModel = DownloadModel(downloadEntity).apply { + pausedByUser?.let { + this.pausedByUser = it + downloadEntity.pausedByUser = it + } + if (shouldUpdateDownloadStatus(status, error, downloadEntity)) { + state = status + } + this.error = error + if (progress > ZERO) { + this.progress = progress + } + this.etaInMilliSeconds = etaInMilliSeconds + if (bytesDownloaded != DEFAULT_INT_VALUE.toLong()) { + this.bytesDownloaded = bytesDownloaded + } + if (totalSizeOfDownload != DEFAULT_INT_VALUE.toLong()) { + this.totalSizeOfDownload = totalSizeOfDownload + } + } + downloadRoomDao.update(downloadModel) + return@let + } + } + } + } + } + /** - * Determines if the DownloadMonitorService should be started. - * Checks if there are active downloads and if the service is not already running. + * Determines whether the download status should be updated based on the current status and error. + * + * This method evaluates the current download status and error conditions, ensuring proper handling + * for paused downloads, queued downloads, and network-related retries. It coordinates with the + * Download Manager to resume downloads when necessary and prevents premature status updates. + * + * @param status The current status of the download. + * @param error The current error state of the download. + * @param downloadRoomEntity The download entity containing the current status and download ID. + * @return `true` if the status should be updated, `false` otherwise. */ - private fun shouldStartDownloadMonitorService(): Boolean = - getActiveDownloads().isNotEmpty() && - !context.isServiceRunning(DownloadMonitorService::class.java) + private fun shouldUpdateDownloadStatus( + status: Status, + error: Error, + downloadRoomEntity: DownloadRoomEntity + ): Boolean { + synchronized(lock) { + return@shouldUpdateDownloadStatus when { + // Check if the download is paused and was previously queued. + isPausedAndQueued(status, downloadRoomEntity) -> + handlePausedAndQueuedDownload(error, downloadRoomEntity) - private fun getActiveDownloads(): List = - downloadRoomDao.downloadRoomEntity().blockingFirst().filter(::isActiveDownload) + // Check if the download is paused and retryable due to network availability. + isPausedAndRetryable( + status, + error, + downloadRoomEntity.pausedByUser + ) -> { + handleRetryablePausedDownload(downloadRoomEntity) + } + + // Default case: update the status. + else -> true + } + } + } /** - * Determines if a given download is considered active. + * Checks if the download is paused and was previously queued. + * + * Specifically, it evaluates whether the current status is "Paused" while the previous status + * was "Queued", indicating that the user might have initiated a resume action. * - * @param download The DownloadRoomEntity to evaluate. - * @return True if the download is active, false otherwise. + * @param status The current status of the download. + * @param downloadRoomEntity The download entity to evaluate. + * @return `true` if the download is paused and queued, `false` otherwise. */ - private fun isActiveDownload(download: DownloadRoomEntity): Boolean = - (download.status != Status.PAUSED || isPausedAndRetryable(download)) && - download.status != Status.CANCELLED + private fun isPausedAndQueued(status: Status, downloadRoomEntity: DownloadRoomEntity): Boolean = + status == Status.PAUSED && downloadRoomEntity.status == Status.QUEUED /** - * Checks if a paused download is eligible for retry based on its error status and network conditions. + * Checks if the download is paused and retryable based on the error and network conditions. * - * @param download The DownloadRoomEntity to evaluate. - * @return True if the paused download is retryable, false otherwise. + * This evaluates whether the download can be resumed, considering its paused state, + * error condition (e.g., waiting for retry), and the availability of a network connection. + * + * @param status The current status of the download. + * @param error The current error state of the download. + * @param pausedByUser To identify if the download paused by user or downloadManager. + * @return `true` if the download is paused and retryable, `false` otherwise. */ - private fun isPausedAndRetryable(download: DownloadRoomEntity): Boolean { - return download.status == Status.PAUSED && - ( - isQueuedForWiFiAndConnected(download) || - isQueuedForNetwork(download) || - download.error == Error.WAITING_TO_RETRY || - download.error == Error.PAUSED_UNKNOWN - ) && + private fun isPausedAndRetryable(status: Status, error: Error, pausedByUser: Boolean): Boolean { + return status == Status.PAUSED && + (error == Error.WAITING_TO_RETRY || error == Error.PAUSED_UNKNOWN) && NetworkUtils.isNetworkAvailable(context) && - !download.pausedByUser + !pausedByUser } /** - * Checks if the download is queued for Wi-Fi and the device is connected to Wi-Fi. + * Handles the case where a paused download was previously queued. + * + * This ensures that the download manager is instructed to resume the download and prevents + * the status from being prematurely updated to "Paused". Instead, the user will see the "Pending" + * state, indicating that the download is in the process of resuming. * - * @param download The DownloadRoomEntity to evaluate. - * @return True if the download is queued for Wi-Fi and connected, false otherwise. + * @param error The current error state of the download. + * @param downloadRoomEntity The download entity to evaluate. + * @return `true` if the status should be updated, `false` otherwise. */ - private fun isQueuedForWiFiAndConnected(download: DownloadRoomEntity): Boolean = - download.error == Error.QUEUED_FOR_WIFI && NetworkUtils.isWiFi(context) + private fun handlePausedAndQueuedDownload( + error: Error, + downloadRoomEntity: DownloadRoomEntity + ): Boolean { + return when (error) { + // When the pause reason is unknown or waiting to retry, and the user + // resumes the download, attempt to resume the download if it was not resumed + // due to some reason. + Error.PAUSED_UNKNOWN, + Error.WAITING_TO_RETRY -> { + resumeDownload(downloadRoomEntity.downloadId) + false + } + + // For any other error state, update the status to reflect the current state + // and provide feedback to the user. + else -> true + } + } /** - * Checks if the download is waiting for a network connection and the network is now available. + * Handles the case where a paused download is retryable due to network availability. * - * @param download The DownloadRoomEntity to evaluate. - * @return True if the download is waiting for a network and connected, false otherwise. + * If the download manager is waiting to retry due to a network error caused by fluctuations, + * this method resumes the download and ensures the status reflects the resumption process. + * + * @param downloadRoomEntity The download entity to evaluate. + * @return `true` to update the status and attempt to resume the download. */ - private fun isQueuedForNetwork(download: DownloadRoomEntity): Boolean = - download.error == Error.WAITING_FOR_NETWORK && NetworkUtils.isNetworkAvailable(context) + private fun handleRetryablePausedDownload(downloadRoomEntity: DownloadRoomEntity): Boolean { + resumeDownload(downloadRoomEntity.downloadId) + return true + } - override fun downloadCompleteOrCancelled(intent: Intent) { + fun pauseDownload(downloadId: Long) { synchronized(lock) { - intent.extras?.let { - val downloadId = it.getLong(DownloadManager.EXTRA_DOWNLOAD_ID, DEFAULT_INT_VALUE.toLong()) - if (downloadId != DEFAULT_INT_VALUE.toLong()) { - try { - context.startService( - getDownloadMonitorIntent( - ACTION_QUERY_DOWNLOAD_STATUS, - downloadId.toInt() - ) - ) - } catch (ignore: Exception) { - // Catching exception if application is not in foreground. - // Since we can not start the foreground services from background. - Log.e( - "DOWNLOAD_MONITOR", - "Couldn't start download service. Original exception = $ignore" - ) - } + updater.onNext { + if (pauseResumeDownloadInDownloadManagerContentResolver( + downloadId, + CONTROL_PAUSE, + STATUS_PAUSED_BY_APP + ) + ) { + // pass true when user paused the download to not retry the download automatically. + updateDownloadStatus(downloadId, Status.PAUSED, Error.NONE, pausedByUser = true) } } } } - private fun startDownloadMonitorService() { - context.startService(Intent(context, DownloadMonitorService::class.java)) - } - - fun pauseDownload(downloadId: Long) { - context.startService(getDownloadMonitorIntent(ACTION_PAUSE, downloadId.toInt())) - startMonitoringDownloads() - } - fun resumeDownload(downloadId: Long) { - context.startService(getDownloadMonitorIntent(ACTION_RESUME, downloadId.toInt())) - startMonitoringDownloads() + synchronized(lock) { + updater.onNext { + if (pauseResumeDownloadInDownloadManagerContentResolver( + downloadId, + CONTROL_RUN, + STATUS_RUNNING + ) + ) { + // pass false when user resumed the download to proceed with further checks. + updateDownloadStatus(downloadId, Status.QUEUED, Error.NONE, pausedByUser = false) + } + } + } } fun cancelDownload(downloadId: Long) { - context.startService(getDownloadMonitorIntent(ACTION_CANCEL, downloadId.toInt())) - startMonitoringDownloads() + synchronized(lock) { + updater.onNext { + // Remove the download from DownloadManager on IO thread. + downloadManager.remove(downloadId) + handleCancelledDownload(downloadId) + } + } } - private fun getDownloadMonitorIntent(action: String, downloadId: Int): Intent = - Intent(context, DownloadMonitorService::class.java).apply { - putExtra(DownloadNotificationManager.NOTIFICATION_ACTION, action) - putExtra(DownloadNotificationManager.EXTRA_DOWNLOAD_ID, downloadId) + @SuppressLint("Range") + private fun pauseResumeDownloadInDownloadManagerContentResolver( + downloadId: Long, + control: Int, + status: Int + ): Boolean { + return try { + // Update the status to paused/resumed in the database + val contentValues = ContentValues().apply { + put(COLUMN_CONTROL, control) + put(DownloadManager.COLUMN_STATUS, status) + } + val uri = ContentUris.withAppendedId(downloadBaseUri, downloadId) + context.contentResolver + .update(uri, contentValues, null, null) + true + } catch (ignore: Exception) { + Log.e("DOWNLOAD_MONITOR", "Couldn't pause/resume the download. Original exception = $ignore") + false } + } + + private fun shouldUpdateDownloadStatus(downloadRoomEntity: DownloadRoomEntity) = + downloadRoomEntity.status != Status.COMPLETED override fun init() { // empty method to so class does not get reported unused } } + +data class DownloadInfo( + var startTime: Long, + var initialBytesDownloaded: Long +) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerRequester.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerRequester.kt index 25f9e6685e..d300105afc 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerRequester.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerRequester.kt @@ -20,7 +20,7 @@ package org.kiwix.kiwixmobile.core.downloader.downloadManager import android.app.DownloadManager import android.app.DownloadManager.Request -import android.app.DownloadManager.Request.VISIBILITY_HIDDEN +import android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED import android.net.Uri import androidx.core.net.toUri import kotlinx.coroutines.CoroutineScope @@ -105,7 +105,7 @@ fun DownloadRequest.toDownloadManagerRequest( Request.NETWORK_MOBILE or Request.NETWORK_WIFI ) setAllowedOverMetered(!sharedPreferenceUtil.prefWifiOnly) - setNotificationVisibility(VISIBILITY_HIDDEN) // hide the default notification. + setNotificationVisibility(VISIBILITY_VISIBLE_NOTIFY_COMPLETED) val userNameAndPassword = System.getenv(urlString.secretKey) ?: "" val userName = userNameAndPassword.substringBefore(":", "") val password = userNameAndPassword.substringAfter(":", "") @@ -123,7 +123,7 @@ fun DownloadRequest.toDownloadManagerRequest( Request.NETWORK_MOBILE or Request.NETWORK_WIFI ) setAllowedOverMetered(!sharedPreferenceUtil.prefWifiOnly) - setNotificationVisibility(VISIBILITY_HIDDEN) // hide the default notification. + setNotificationVisibility(VISIBILITY_VISIBLE_NOTIFY_COMPLETED) } } } 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 deleted file mode 100644 index 19f76b8711..0000000000 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt +++ /dev/null @@ -1,744 +0,0 @@ -/* - * Kiwix Android - * Copyright (c) 2024 Kiwix - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package org.kiwix.kiwixmobile.core.downloader.downloadManager - -import android.annotation.SuppressLint -import android.app.DownloadManager -import android.app.Service -import android.content.ContentUris -import android.content.ContentValues -import android.content.Intent -import android.database.Cursor -import android.net.Uri -import android.os.IBinder -import io.reactivex.Observable -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers -import io.reactivex.subjects.PublishSubject -import org.kiwix.kiwixmobile.core.CoreApp -import org.kiwix.kiwixmobile.core.dao.DownloadRoomDao -import org.kiwix.kiwixmobile.core.dao.entities.DownloadRoomEntity -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager.Companion.ACTION_CANCEL -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager.Companion.ACTION_PAUSE -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager.Companion.ACTION_QUERY_DOWNLOAD_STATUS -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager.Companion.ACTION_RESUME -import org.kiwix.kiwixmobile.core.downloader.model.DownloadModel -import org.kiwix.kiwixmobile.core.downloader.model.DownloadState -import org.kiwix.kiwixmobile.core.utils.NetworkUtils -import org.kiwix.kiwixmobile.core.utils.files.Log -import java.util.concurrent.TimeUnit -import javax.inject.Inject - -const val ZERO = 0 -const val HUNDERED = 100 -const val THOUSAND = 1000 -const val DEFAULT_INT_VALUE = -1 - -/* - These below values of android.provider.Downloads.Impl class, - there is no direct way to access them so we defining the values - from https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/core/java/android/provider/Downloads.java - */ -const val CONTROL_PAUSE = 1 -const val CONTROL_RUN = 0 -const val STATUS_RUNNING = 192 -const val STATUS_PAUSED_BY_APP = 193 -const val COLUMN_CONTROL = "control" -val downloadBaseUri: Uri = Uri.parse("content://downloads/my_downloads") - -class DownloadMonitorService : Service() { - - @Inject - lateinit var downloadManager: DownloadManager - - @Inject - lateinit var downloadRoomDao: DownloadRoomDao - - @Inject - lateinit var downloadNotificationManager: DownloadNotificationManager - private val lock = Any() - private var monitoringDisposable: Disposable? = null - private val downloadInfoMap = mutableMapOf() - private val updater = PublishSubject.create<() -> Unit>() - private var foreGroundServiceInformation: Pair = true to DEFAULT_INT_VALUE - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onCreate() { - CoreApp.coreComponent - .coreServiceComponent() - .service(this) - .build() - .inject(this) - super.onCreate() - setupUpdater() - startMonitoringDownloads() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val downloadId = - intent?.getIntExtra(DownloadNotificationManager.EXTRA_DOWNLOAD_ID, DEFAULT_INT_VALUE) - ?: DEFAULT_INT_VALUE - val notificationAction = intent?.getStringExtra(DownloadNotificationManager.NOTIFICATION_ACTION) - if (downloadId != DEFAULT_INT_VALUE) { - when (notificationAction) { - ACTION_PAUSE -> pauseDownload(downloadId.toLong()) - ACTION_RESUME -> resumeDownload(downloadId.toLong()) - ACTION_CANCEL -> cancelDownload(downloadId.toLong()) - ACTION_QUERY_DOWNLOAD_STATUS -> { - updater.onNext { - queryDownloadStatus(downloadId.toLong()) - } - } - } - } - return START_NOT_STICKY - } - - @Suppress("CheckResult") - private fun setupUpdater() { - updater.subscribeOn(Schedulers.io()).observeOn(Schedulers.io()).subscribe( - { - synchronized(lock) { it.invoke() } - }, - Throwable::printStackTrace - ) - } - - /** - * Starts monitoring ongoing downloads using a periodic observable. - * This method sets up an observable that runs every 5 seconds to check the status of downloads. - * It only starts the monitoring process if it's not already running and disposes of the observable - * when there are no ongoing downloads to avoid unnecessary resource usage. - */ - @Suppress("MagicNumber") - private fun startMonitoringDownloads() { - // Check if monitoring is already active. If it is, do nothing. - if (monitoringDisposable?.isDisposed == false) return - monitoringDisposable = Observable.interval(ZERO.toLong(), 5, TimeUnit.SECONDS) - .subscribeOn(Schedulers.io()) - .observeOn(Schedulers.io()) - .subscribe( - { - try { - synchronized(lock) { - if (downloadRoomDao.downloads().blockingFirst().isNotEmpty()) { - checkDownloads() - } else { - stopForegroundServiceForDownloads() - } - } - } catch (ignore: Exception) { - Log.e( - "DOWNLOAD_MONITOR", - "Couldn't get the downloads update. Original exception = $ignore" - ) - } - }, - Throwable::printStackTrace - ) - } - - @SuppressLint("Range") - private fun checkDownloads() { - synchronized(lock) { - val query = DownloadManager.Query().setFilterByStatus( - DownloadManager.STATUS_RUNNING or - DownloadManager.STATUS_PAUSED or - DownloadManager.STATUS_PENDING or - DownloadManager.STATUS_SUCCESSFUL - ) - downloadManager.query(query).use { cursor -> - if (cursor.moveToFirst()) { - do { - val downloadId = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_ID)) - queryDownloadStatus(downloadId) - } while (cursor.moveToNext()) - } - } - } - } - - @SuppressLint("Range") - fun queryDownloadStatus(downloadId: Long) { - synchronized(lock) { - downloadManager.query(DownloadManager.Query().setFilterById(downloadId)).use { cursor -> - if (cursor.moveToFirst()) { - handleDownloadStatus(cursor, downloadId) - } else { - handleCancelledDownload(downloadId) - } - } - } - } - - @SuppressLint("Range") - private fun handleDownloadStatus(cursor: Cursor, downloadId: Long) { - val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) - val reason = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON)) - val bytesDownloaded = - cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) - val totalBytes = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) - val progress = calculateProgress(bytesDownloaded, totalBytes) - - val etaInMilliSeconds = calculateETA(downloadId, bytesDownloaded, totalBytes) - - when (status) { - DownloadManager.STATUS_FAILED -> handleFailedDownload( - downloadId, - reason, - progress, - etaInMilliSeconds, - bytesDownloaded, - totalBytes - ) - - DownloadManager.STATUS_PAUSED -> handlePausedDownload( - downloadId, - progress, - bytesDownloaded, - totalBytes, - reason - ) - - DownloadManager.STATUS_PENDING -> handlePendingDownload(downloadId) - DownloadManager.STATUS_RUNNING -> handleRunningDownload( - downloadId, - progress, - etaInMilliSeconds, - bytesDownloaded, - totalBytes - ) - - DownloadManager.STATUS_SUCCESSFUL -> handleSuccessfulDownload( - downloadId, - progress, - etaInMilliSeconds - ) - } - } - - private fun handleCancelledDownload(downloadId: Long) { - updater.onNext { - updateDownloadStatus(downloadId, Status.CANCELLED, Error.CANCELLED) - downloadRoomDao.delete(downloadId) - downloadInfoMap.remove(downloadId) - } - } - - @Suppress("LongParameterList") - private fun handleFailedDownload( - downloadId: Long, - reason: Int, - progress: Int, - etaInMilliSeconds: Long, - bytesDownloaded: Long, - totalBytes: Long - ) { - val error = mapDownloadError(reason) - updateDownloadStatus( - downloadId, - Status.FAILED, - error, - progress, - etaInMilliSeconds, - bytesDownloaded, - totalBytes - ) - } - - private fun handlePausedDownload( - downloadId: Long, - progress: Int, - bytesDownloaded: Long, - totalSizeOfDownload: Long, - reason: Int - ) { - val pauseReason = mapDownloadPauseReason(reason) - updateDownloadStatus( - downloadId = downloadId, - status = Status.PAUSED, - error = pauseReason, - progress = progress, - bytesDownloaded = bytesDownloaded, - totalSizeOfDownload = totalSizeOfDownload - ) - } - - private fun handlePendingDownload(downloadId: Long) { - updateDownloadStatus( - downloadId, - Status.QUEUED, - Error.NONE - ) - } - - private fun handleRunningDownload( - downloadId: Long, - progress: Int, - etaInMilliSeconds: Long, - bytesDownloaded: Long, - totalSizeOfDownload: Long - ) { - updateDownloadStatus( - downloadId, - Status.DOWNLOADING, - Error.NONE, - progress, - etaInMilliSeconds, - bytesDownloaded, - totalSizeOfDownload - ) - } - - private fun handleSuccessfulDownload( - downloadId: Long, - progress: Int, - etaInMilliSeconds: Long - ) { - updateDownloadStatus( - downloadId, - Status.COMPLETED, - Error.NONE, - progress, - etaInMilliSeconds - ) - downloadInfoMap.remove(downloadId) - } - - private fun calculateProgress(bytesDownloaded: Long, totalBytes: Long): Int = - if (totalBytes > ZERO) { - (bytesDownloaded / totalBytes.toDouble()).times(HUNDERED).toInt() - } else { - ZERO - } - - private fun calculateETA(downloadedFileId: Long, bytesDownloaded: Long, totalBytes: Long): Long { - val currentTime = System.currentTimeMillis() - val downloadInfo = downloadInfoMap.getOrPut(downloadedFileId) { - DownloadInfo(startTime = currentTime, initialBytesDownloaded = bytesDownloaded) - } - - val elapsedTime = currentTime - downloadInfo.startTime - val downloadSpeed = if (elapsedTime > ZERO) { - (bytesDownloaded - downloadInfo.initialBytesDownloaded) / (elapsedTime / THOUSAND.toFloat()) - } else { - ZERO.toFloat() - } - - return if (downloadSpeed > ZERO) { - ((totalBytes - bytesDownloaded) / downloadSpeed).toLong() * THOUSAND - } else { - ZERO.toLong() - } - } - - private fun mapDownloadError(reason: Int): Error { - return when (reason) { - DownloadManager.ERROR_CANNOT_RESUME -> Error.ERROR_CANNOT_RESUME - DownloadManager.ERROR_DEVICE_NOT_FOUND -> Error.ERROR_DEVICE_NOT_FOUND - DownloadManager.ERROR_FILE_ALREADY_EXISTS -> Error.ERROR_FILE_ALREADY_EXISTS - DownloadManager.ERROR_FILE_ERROR -> Error.ERROR_FILE_ERROR - DownloadManager.ERROR_HTTP_DATA_ERROR -> Error.ERROR_HTTP_DATA_ERROR - DownloadManager.ERROR_INSUFFICIENT_SPACE -> Error.ERROR_INSUFFICIENT_SPACE - DownloadManager.ERROR_TOO_MANY_REDIRECTS -> Error.ERROR_TOO_MANY_REDIRECTS - DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> Error.ERROR_UNHANDLED_HTTP_CODE - DownloadManager.ERROR_UNKNOWN -> Error.UNKNOWN - else -> Error.UNKNOWN - } - } - - private fun mapDownloadPauseReason(reason: Int): Error { - return when (reason) { - DownloadManager.PAUSED_QUEUED_FOR_WIFI -> Error.QUEUED_FOR_WIFI - DownloadManager.PAUSED_WAITING_TO_RETRY -> Error.WAITING_TO_RETRY - DownloadManager.PAUSED_WAITING_FOR_NETWORK -> Error.WAITING_FOR_NETWORK - DownloadManager.PAUSED_UNKNOWN -> Error.PAUSED_UNKNOWN - else -> Error.PAUSED_UNKNOWN - } - } - - @Suppress("LongParameterList") - private fun updateDownloadStatus( - downloadId: Long, - status: Status, - error: Error, - progress: Int = DEFAULT_INT_VALUE, - etaInMilliSeconds: Long = DEFAULT_INT_VALUE.toLong(), - bytesDownloaded: Long = DEFAULT_INT_VALUE.toLong(), - totalSizeOfDownload: Long = DEFAULT_INT_VALUE.toLong(), - pausedByUser: Boolean? = null - ) { - synchronized(lock) { - updater.onNext { - downloadRoomDao.getEntityForDownloadId(downloadId)?.let { downloadEntity -> - if (shouldUpdateDownloadStatus(downloadEntity)) { - val downloadModel = DownloadModel(downloadEntity).apply { - pausedByUser?.let { - this.pausedByUser = it - downloadEntity.pausedByUser = it - } - if (shouldUpdateDownloadStatus(status, error, downloadEntity)) { - state = status - } - this.error = error - if (progress > ZERO) { - this.progress = progress - } - this.etaInMilliSeconds = etaInMilliSeconds - if (bytesDownloaded != DEFAULT_INT_VALUE.toLong()) { - this.bytesDownloaded = bytesDownloaded - } - if (totalSizeOfDownload != DEFAULT_INT_VALUE.toLong()) { - this.totalSizeOfDownload = totalSizeOfDownload - } - } - downloadRoomDao.update(downloadModel) - updateNotification(downloadModel, downloadEntity.title, downloadEntity.description) - return@let - } - cancelNotificationAndAssignNewNotificationToForegroundService(downloadId) - } ?: run { - // already downloaded/cancelled so cancel the notification if any running, and - // assign new notification to foreground service. - cancelNotificationAndAssignNewNotificationToForegroundService(downloadId) - } - } - } - } - - /** - * Determines whether the download status should be updated based on the current status and error. - * - * This method evaluates the current download status and error conditions, ensuring proper handling - * for paused downloads, queued downloads, and network-related retries. It coordinates with the - * Download Manager to resume downloads when necessary and prevents premature status updates. - * - * @param status The current status of the download. - * @param error The current error state of the download. - * @param downloadRoomEntity The download entity containing the current status and download ID. - * @return `true` if the status should be updated, `false` otherwise. - */ - private fun shouldUpdateDownloadStatus( - status: Status, - error: Error, - downloadRoomEntity: DownloadRoomEntity - ): Boolean { - synchronized(lock) { - return@shouldUpdateDownloadStatus when { - // Check if the download is paused and was previously queued. - isPausedAndQueued(status, downloadRoomEntity) -> - handlePausedAndQueuedDownload(error, downloadRoomEntity) - - // Check if the download is paused and retryable due to network availability. - isPausedAndRetryable( - status, - error, - downloadRoomEntity.pausedByUser - ) -> { - handleRetryablePausedDownload(downloadRoomEntity) - } - - // Default case: update the status. - else -> true - } - } - } - - /** - * Checks if the download is paused and was previously queued. - * - * Specifically, it evaluates whether the current status is "Paused" while the previous status - * was "Queued", indicating that the user might have initiated a resume action. - * - * @param status The current status of the download. - * @param downloadRoomEntity The download entity to evaluate. - * @return `true` if the download is paused and queued, `false` otherwise. - */ - private fun isPausedAndQueued(status: Status, downloadRoomEntity: DownloadRoomEntity): Boolean = - status == Status.PAUSED && downloadRoomEntity.status == Status.QUEUED - - /** - * Checks if the download is paused and retryable based on the error and network conditions. - * - * This evaluates whether the download can be resumed, considering its paused state, - * error condition (e.g., waiting for retry), and the availability of a network connection. - * - * @param status The current status of the download. - * @param error The current error state of the download. - * @param pausedByUser To identify if the download paused by user or downloadManager. - * @return `true` if the download is paused and retryable, `false` otherwise. - */ - private fun isPausedAndRetryable(status: Status, error: Error, pausedByUser: Boolean): Boolean { - return status == Status.PAUSED && - (error == Error.WAITING_TO_RETRY || error == Error.PAUSED_UNKNOWN) && - NetworkUtils.isNetworkAvailable(this) && - !pausedByUser - } - - /** - * Handles the case where a paused download was previously queued. - * - * This ensures that the download manager is instructed to resume the download and prevents - * the status from being prematurely updated to "Paused". Instead, the user will see the "Pending" - * state, indicating that the download is in the process of resuming. - * - * @param error The current error state of the download. - * @param downloadRoomEntity The download entity to evaluate. - * @return `true` if the status should be updated, `false` otherwise. - */ - private fun handlePausedAndQueuedDownload( - error: Error, - downloadRoomEntity: DownloadRoomEntity - ): Boolean { - return when (error) { - // When the pause reason is unknown or waiting to retry, and the user - // resumes the download, attempt to resume the download if it was not resumed - // due to some reason. - Error.PAUSED_UNKNOWN, - Error.WAITING_TO_RETRY -> { - resumeDownload(downloadRoomEntity.downloadId) - false - } - - // For any other error state, update the status to reflect the current state - // and provide feedback to the user. - else -> true - } - } - - /** - * Handles the case where a paused download is retryable due to network availability. - * - * If the download manager is waiting to retry due to a network error caused by fluctuations, - * this method resumes the download and ensures the status reflects the resumption process. - * - * @param downloadRoomEntity The download entity to evaluate. - * @return `true` to update the status and attempt to resume the download. - */ - private fun handleRetryablePausedDownload(downloadRoomEntity: DownloadRoomEntity): Boolean { - resumeDownload(downloadRoomEntity.downloadId) - return true - } - - private fun cancelNotificationAndAssignNewNotificationToForegroundService(downloadId: Long) { - downloadNotificationManager.cancelNotification(downloadId.toInt()) - updateForegroundNotificationOrStopService() - } - - private fun updateForegroundNotificationOrStopService() { - val activeDownloads = getActiveDownloads() - if (activeDownloads.isNotEmpty()) { - // Promote the first active download to foreground - val downloadRoomEntity = activeDownloads.first() - foreGroundServiceInformation = - foreGroundServiceInformation.first to downloadRoomEntity.downloadId.toInt() - val downloadNotificationModel = - getDownloadNotificationModel( - DownloadModel(downloadRoomEntity), - downloadRoomEntity.title, - downloadRoomEntity.description - ) - val notification = downloadNotificationManager.createNotification(downloadNotificationModel) - startForeground(foreGroundServiceInformation.second, notification) - } else { - // Stop the service if no active downloads remain - stopForegroundServiceForDownloads() - } - } - - private fun getActiveDownloads(): List = - downloadRoomDao.downloadRoomEntity().blockingFirst().filter(::isActiveDownload) - - /** - * Determines if a given download is considered active. - * - * @param download The DownloadRoomEntity to evaluate. - * @return True if the download is active, false otherwise. - */ - private fun isActiveDownload(download: DownloadRoomEntity): Boolean = - (download.status != Status.PAUSED || isPausedAndRetryable(download)) && - download.status != Status.CANCELLED - - /** - * Checks if a paused download is eligible for retry based on its error status and network conditions. - * - * @param download The DownloadRoomEntity to evaluate. - * @return True if the paused download is retryable, false otherwise. - */ - private fun isPausedAndRetryable(download: DownloadRoomEntity): Boolean { - return download.status == Status.PAUSED && - ( - isQueuedForWiFiAndConnected(download) || - isQueuedForNetwork(download) || - download.error == Error.WAITING_TO_RETRY || - download.error == Error.PAUSED_UNKNOWN - ) && - NetworkUtils.isNetworkAvailable(this) && - !download.pausedByUser - } - - /** - * Checks if the download is queued for Wi-Fi and the device is connected to Wi-Fi. - * - * @param download The DownloadRoomEntity to evaluate. - * @return True if the download is queued for Wi-Fi and connected, false otherwise. - */ - private fun isQueuedForWiFiAndConnected(download: DownloadRoomEntity): Boolean = - download.error == Error.QUEUED_FOR_WIFI && NetworkUtils.isWiFi(this) - - /** - * Checks if the download is waiting for a network connection and the network is now available. - * - * @param download The DownloadRoomEntity to evaluate. - * @return True if the download is waiting for a network and connected, false otherwise. - */ - private fun isQueuedForNetwork(download: DownloadRoomEntity): Boolean = - download.error == Error.WAITING_FOR_NETWORK && NetworkUtils.isNetworkAvailable(this) - - private fun updateNotification( - downloadModel: DownloadModel, - title: String, - description: String? - ) { - val downloadNotificationModel = getDownloadNotificationModel(downloadModel, title, description) - val notification = downloadNotificationManager.createNotification(downloadNotificationModel) - if (foreGroundServiceInformation.first) { - startForeground(downloadModel.downloadId.toInt(), notification) - foreGroundServiceInformation = false to downloadModel.downloadId.toInt() - } else { - downloadNotificationManager.updateNotification( - downloadNotificationModel, - object : AssignNewForegroundServiceNotification { - override fun assignNewForegroundServiceNotification(downloadId: Long) { - cancelNotificationAndAssignNewNotificationToForegroundService(downloadId) - } - } - ) - } - } - - private fun getDownloadNotificationModel( - downloadModel: DownloadModel, - title: String, - description: String? - ): DownloadNotificationModel = - DownloadNotificationModel( - downloadId = downloadModel.downloadId.toInt(), - status = downloadModel.state, - progress = downloadModel.progress, - etaInMilliSeconds = downloadModel.etaInMilliSeconds, - title = title, - description = description, - filePath = downloadModel.file, - error = DownloadState.from( - downloadModel.state, - downloadModel.error, - downloadModel.book.url - ).toReadableState(this).toString() - ) - - private fun pauseDownload(downloadId: Long) { - synchronized(lock) { - updater.onNext { - if (pauseResumeDownloadInDownloadManagerContentResolver( - downloadId, - CONTROL_PAUSE, - STATUS_PAUSED_BY_APP - ) - ) { - // pass true when user paused the download to not retry the download automatically. - updateDownloadStatus(downloadId, Status.PAUSED, Error.NONE, pausedByUser = true) - } - } - } - } - - private fun resumeDownload(downloadId: Long) { - synchronized(lock) { - updater.onNext { - if (pauseResumeDownloadInDownloadManagerContentResolver( - downloadId, - CONTROL_RUN, - STATUS_RUNNING - ) - ) { - // pass false when user resumed the download to proceed with further checks. - updateDownloadStatus(downloadId, Status.QUEUED, Error.NONE, pausedByUser = false) - } - } - } - } - - private fun cancelDownload(downloadId: Long) { - synchronized(lock) { - updater.onNext { - // Remove the download from DownloadManager on IO thread. - downloadManager.remove(downloadId) - handleCancelledDownload(downloadId) - } - } - } - - @SuppressLint("Range") - private fun pauseResumeDownloadInDownloadManagerContentResolver( - downloadId: Long, - control: Int, - status: Int - ): Boolean { - return try { - // Update the status to paused/resumed in the database - val contentValues = ContentValues().apply { - put(COLUMN_CONTROL, control) - put(DownloadManager.COLUMN_STATUS, status) - } - val uri = ContentUris.withAppendedId(downloadBaseUri, downloadId) - contentResolver - .update(uri, contentValues, null, null) - true - } catch (ignore: Exception) { - Log.e("DOWNLOAD_MONITOR", "Couldn't pause/resume the download. Original exception = $ignore") - false - } - } - - private fun shouldUpdateDownloadStatus(downloadRoomEntity: DownloadRoomEntity) = - downloadRoomEntity.status != Status.COMPLETED - - override fun onDestroy() { - monitoringDisposable?.dispose() - super.onDestroy() - } - - private fun stopForegroundServiceForDownloads() { - foreGroundServiceInformation = true to DEFAULT_INT_VALUE - monitoringDisposable?.dispose() - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } -} - -data class DownloadInfo( - var startTime: Long, - var initialBytesDownloaded: Long -) - -interface AssignNewForegroundServiceNotification { - fun assignNewForegroundServiceNotification(downloadId: Long) -} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationManager.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationManager.kt deleted file mode 100644 index e7c8600efd..0000000000 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationManager.kt +++ /dev/null @@ -1,259 +0,0 @@ -/* - * Kiwix Android - * Copyright (c) 2024 Kiwix - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -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.content.Context -import android.content.Intent -import android.os.Build -import androidx.annotation.RequiresApi -import androidx.core.app.NotificationCompat -import org.kiwix.kiwixmobile.core.Intents -import org.kiwix.kiwixmobile.core.R -import org.kiwix.kiwixmobile.core.downloader.model.Seconds -import org.kiwix.kiwixmobile.core.main.CoreMainActivity -import org.kiwix.kiwixmobile.core.utils.DEFAULT_NOTIFICATION_TIMEOUT_AFTER -import org.kiwix.kiwixmobile.core.utils.DEFAULT_NOTIFICATION_TIMEOUT_AFTER_RESET -import org.kiwix.kiwixmobile.core.utils.DOWNLOAD_NOTIFICATION_CHANNEL_ID -import java.util.Locale -import javax.inject.Inject - -const val DOWNLOAD_NOTIFICATION_TITLE = "OPEN_ZIM_FILE" - -class DownloadNotificationManager @Inject constructor( - private val context: Context, - private val notificationManager: NotificationManager -) { - private val downloadNotificationsBuilderMap = mutableMapOf() - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (notificationManager.getNotificationChannel(DOWNLOAD_NOTIFICATION_CHANNEL_ID) == null) { - notificationManager.createNotificationChannel(createChannel(context)) - } - } - } - - fun updateNotification( - downloadNotificationModel: DownloadNotificationModel, - assignNewForegroundServiceNotification: AssignNewForegroundServiceNotification - ) { - synchronized(downloadNotificationsBuilderMap) { - if (shouldUpdateNotification(downloadNotificationModel)) { - notificationManager.notify( - downloadNotificationModel.downloadId, - createNotification(downloadNotificationModel) - ) - } else { - // the download is cancelled/paused so remove the notification. - assignNewForegroundServiceNotification.assignNewForegroundServiceNotification( - downloadNotificationModel.downloadId.toLong() - ) - } - } - } - - fun createNotification(downloadNotificationModel: DownloadNotificationModel): Notification { - synchronized(downloadNotificationsBuilderMap) { - createNotificationChannel() - val notificationBuilder = getNotificationBuilder(downloadNotificationModel.downloadId) - val smallIcon = if (downloadNotificationModel.progress != HUNDERED) { - android.R.drawable.stat_sys_download - } else { - android.R.drawable.stat_sys_download_done - } - - notificationBuilder.setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setSmallIcon(smallIcon) - .setContentTitle(downloadNotificationModel.title) - .setContentText(getSubtitleText(context, downloadNotificationModel)) - .setOngoing(downloadNotificationModel.isOnGoingNotification) - .setGroupSummary(false) - if (downloadNotificationModel.isFailed || downloadNotificationModel.isCompleted) { - notificationBuilder.setProgress(ZERO, ZERO, false) - } else { - notificationBuilder.setProgress(HUNDERED, downloadNotificationModel.progress, false) - } - when { - downloadNotificationModel.isDownloading -> - notificationBuilder.setTimeoutAfter(DEFAULT_NOTIFICATION_TIMEOUT_AFTER) - .addAction( - R.drawable.ic_baseline_stop, - context.getString(R.string.cancel), - getActionPendingIntent(ACTION_CANCEL, downloadNotificationModel.downloadId) - ).addAction( - R.drawable.ic_baseline_pause, - getPauseOrResumeTitle(true), - getActionPendingIntent(ACTION_PAUSE, downloadNotificationModel.downloadId) - ) - - downloadNotificationModel.isPaused -> - notificationBuilder.setTimeoutAfter(DEFAULT_NOTIFICATION_TIMEOUT_AFTER) - .addAction( - R.drawable.ic_baseline_stop, - context.getString(R.string.cancel), - getActionPendingIntent(ACTION_CANCEL, downloadNotificationModel.downloadId) - ).addAction( - R.drawable.ic_baseline_play, - getPauseOrResumeTitle(false), - getActionPendingIntent(ACTION_RESUME, downloadNotificationModel.downloadId) - ) - - downloadNotificationModel.isQueued -> - notificationBuilder.setTimeoutAfter(DEFAULT_NOTIFICATION_TIMEOUT_AFTER) - - else -> notificationBuilder.setTimeoutAfter(DEFAULT_NOTIFICATION_TIMEOUT_AFTER_RESET) - } - notificationCustomisation(downloadNotificationModel, notificationBuilder, context) - return@createNotification notificationBuilder.build() - } - } - - private fun getPauseOrResumeTitle(isPause: Boolean): String { - val pauseOrResumeTitle = if (isPause) { - context.getString(R.string.tts_pause) - } else { - context.getString(R.string.tts_resume) - } - return pauseOrResumeTitle.replaceFirstChar { - if (it.isLowerCase()) { - it.titlecase(Locale.ROOT) - } else { - "$it" - } - } - } - - private fun shouldUpdateNotification( - downloadNotificationModel: DownloadNotificationModel - ): Boolean = !downloadNotificationModel.isCancelled && !downloadNotificationModel.isPaused - - @SuppressLint("UnspecifiedImmutableFlag") - private fun notificationCustomisation( - downloadNotificationModel: DownloadNotificationModel, - notificationBuilder: NotificationCompat.Builder, - context: Context - ) { - if (downloadNotificationModel.isCompleted) { - val internal = Intents.internal(CoreMainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - putExtra(DOWNLOAD_NOTIFICATION_TITLE, downloadNotificationModel.filePath) - } - val pendingIntent = - PendingIntent.getActivity( - context, - ZERO, - internal, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - notificationBuilder.setContentIntent(pendingIntent) - notificationBuilder.setAutoCancel(true) - } - } - - @SuppressLint("RestrictedApi") - private fun getNotificationBuilder(notificationId: Int): NotificationCompat.Builder { - synchronized(downloadNotificationsBuilderMap) { - val notificationBuilder = downloadNotificationsBuilderMap[notificationId] - ?: NotificationCompat.Builder(context, DOWNLOAD_NOTIFICATION_CHANNEL_ID) - downloadNotificationsBuilderMap[notificationId] = notificationBuilder - notificationBuilder - .setGroup("$notificationId") - .setStyle(null) - .setProgress(ZERO, ZERO, false) - .setContentTitle(null) - .setContentText(null) - .setContentIntent(null) - .setGroupSummary(false) - .setTimeoutAfter(DEFAULT_NOTIFICATION_TIMEOUT_AFTER_RESET) - .setOngoing(false) - .setOnlyAlertOnce(true) - .setSmallIcon(android.R.drawable.stat_sys_download_done) - .mActions.clear() - return@getNotificationBuilder notificationBuilder - } - } - - private fun getActionPendingIntent(action: String, downloadId: Int): PendingIntent { - val pendingIntent = - Intent(context, DownloadMonitorService::class.java).apply { - putExtra(NOTIFICATION_ACTION, action) - putExtra(EXTRA_DOWNLOAD_ID, downloadId) - } - val requestCode = downloadId + action.hashCode() - return PendingIntent.getService( - context, - requestCode, - pendingIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createChannel(context: Context) = - NotificationChannel( - DOWNLOAD_NOTIFICATION_CHANNEL_ID, - context.getString(R.string.download_notification_channel_name), - NotificationManager.IMPORTANCE_HIGH - ).apply { - setSound(null, null) - enableVibration(false) - } - - private fun getSubtitleText( - context: Context, - downloadNotificationModel: DownloadNotificationModel - ): String { - return when { - downloadNotificationModel.isCompleted -> context.getString(R.string.complete) - downloadNotificationModel.isFailed -> context.getString( - R.string.failed_state, - downloadNotificationModel.error - ) - - downloadNotificationModel.isPaused -> context.getString(R.string.paused_state) - downloadNotificationModel.isQueued -> context.getString(R.string.pending_state) - downloadNotificationModel.etaInMilliSeconds <= ZERO -> - context.getString(R.string.running_state) - - else -> Seconds( - downloadNotificationModel.etaInMilliSeconds / THOUSAND.toLong() - ).toHumanReadableTime() - } - } - - fun cancelNotification(notificationId: Int) { - synchronized(downloadNotificationsBuilderMap) { - notificationManager.cancel(notificationId) - downloadNotificationsBuilderMap.remove(notificationId) - } - } - - companion object { - const val NOTIFICATION_ACTION = "notification_action" - const val ACTION_PAUSE = "action_pause" - const val ACTION_RESUME = "action_resume" - const val ACTION_CANCEL = "action_cancel" - const val ACTION_QUERY_DOWNLOAD_STATUS = "action_query_download_status" - const val EXTRA_DOWNLOAD_ID = "extra_download_id" - } -} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationModel.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationModel.kt deleted file mode 100644 index 17f2de2c9e..0000000000 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationModel.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Kiwix Android - * Copyright (c) 2024 Kiwix - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package org.kiwix.kiwixmobile.core.downloader.downloadManager - -data class DownloadNotificationModel( - val downloadId: Int, - val status: Status = Status.NONE, - val progress: Int, - val etaInMilliSeconds: Long, - val title: String, - val description: String?, - val filePath: String?, - val error: String -) { - val isPaused get() = status == Status.PAUSED - val isCompleted get() = status == Status.COMPLETED - val isFailed get() = status == Status.FAILED - val isQueued get() = status == Status.QUEUED - val isDownloading get() = status == Status.DOWNLOADING - val isCancelled get() = status == Status.CANCELLED - val isOnGoingNotification: Boolean - get() { - return when (status) { - Status.QUEUED, - Status.DOWNLOADING -> true - - else -> false - } - } -} From 8f5f6cb2941df94e828c6957a5f9af98d48d1f11 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Mon, 16 Dec 2024 15:19:27 +0530 Subject: [PATCH 10/12] Improved the query to DownloadManager. * Removed the unnecessary query to download manager for previous downloads. Now we are only making request to active downloads which are in our download DAO. --- .../downloadManager/DownloadManagerMonitor.kt | 34 +++++++------------ 1 file changed, 13 insertions(+), 21 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 8e0964c1fe..3191c0d79f 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 @@ -110,8 +110,9 @@ class DownloadManagerMonitor @Inject constructor( { try { synchronized(lock) { - if (downloadRoomDao.downloads().blockingFirst().isNotEmpty()) { - checkDownloads() + val downloadingList = downloadRoomDao.downloads().blockingFirst() + if (downloadingList.isNotEmpty()) { + checkDownloads(downloadingList) } else { monitoringDisposable?.dispose() } @@ -128,21 +129,10 @@ class DownloadManagerMonitor @Inject constructor( } @SuppressLint("Range") - private fun checkDownloads() { + private fun checkDownloads(downloadingList: List) { synchronized(lock) { - val query = DownloadManager.Query().setFilterByStatus( - DownloadManager.STATUS_RUNNING or - DownloadManager.STATUS_PAUSED or - DownloadManager.STATUS_PENDING or - DownloadManager.STATUS_SUCCESSFUL - ) - downloadManager.query(query).use { cursor -> - if (cursor.moveToFirst()) { - do { - val downloadId = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_ID)) - queryDownloadStatus(downloadId) - } while (cursor.moveToNext()) - } + downloadingList.forEach { + queryDownloadStatus(it.downloadId) } } } @@ -150,11 +140,13 @@ class DownloadManagerMonitor @Inject constructor( @SuppressLint("Range") fun queryDownloadStatus(downloadId: Long) { synchronized(lock) { - downloadManager.query(DownloadManager.Query().setFilterById(downloadId)).use { cursor -> - if (cursor.moveToFirst()) { - handleDownloadStatus(cursor, downloadId) - } else { - handleCancelledDownload(downloadId) + updater.onNext { + downloadManager.query(DownloadManager.Query().setFilterById(downloadId)).use { cursor -> + if (cursor.moveToFirst()) { + handleDownloadStatus(cursor, downloadId) + } else { + handleCancelledDownload(downloadId) + } } } } From 068508d74623af8befb626b2c516e262f867a408 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Mon, 16 Dec 2024 15:40:53 +0530 Subject: [PATCH 11/12] Fixed: The fileName was showing in notification instead of book name. --- .../org/kiwix/kiwixmobile/core/dao/DownloadRoomDao.kt | 2 +- .../downloader/downloadManager/DownloadManagerMonitor.kt | 9 ++++++++- .../downloadManager/DownloadManagerRequester.kt | 4 +++- .../kiwixmobile/core/downloader/model/DownloadRequest.kt | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/dao/DownloadRoomDao.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/dao/DownloadRoomDao.kt index 6f21298c13..0d4aafcbda 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/dao/DownloadRoomDao.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/dao/DownloadRoomDao.kt @@ -113,7 +113,7 @@ abstract class DownloadRoomDao { sharedPreferenceUtil: SharedPreferenceUtil ) { if (doesNotAlreadyExist(book)) { - val downloadRequest = DownloadRequest(url) + val downloadRequest = DownloadRequest(url, book.title) saveDownload( DownloadRoomEntity( url, 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 3191c0d79f..785c83cc8e 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 @@ -351,7 +351,14 @@ class DownloadManagerMonitor @Inject constructor( ) { synchronized(lock) { updater.onNext { - Log.e("DOWNLOAD_MONITOR", "updateDownloadStatus: $status \n $error \n $progress") + Log.e( + "DOWNLOAD_MONITOR", + "updateDownloadStatus: " + + "\n Status = $status" + + "\n Error = $error" + + "\n Progress = $progress" + + "\n DownloadId = $downloadId" + ) downloadRoomDao.getEntityForDownloadId(downloadId)?.let { downloadEntity -> if (shouldUpdateDownloadStatus(downloadEntity)) { val downloadModel = DownloadModel(downloadEntity).apply { diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerRequester.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerRequester.kt index d300105afc..616560f16a 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerRequester.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerRequester.kt @@ -60,7 +60,7 @@ class DownloadManagerRequester @Inject constructor( .downloadRoomDao .getEntityForDownloadId(downloadId)?.let { downloadRoomEntity -> downloadRoomEntity.url?.let { - val downloadRequest = DownloadRequest(urlString = it) + val downloadRequest = DownloadRequest(urlString = it, downloadRoomEntity.title) val newDownloadEntity = downloadRoomEntity.copy(downloadId = enqueue(downloadRequest), id = 0) // cancel the previous download and its data from database and fileSystem. @@ -97,6 +97,7 @@ fun DownloadRequest.toDownloadManagerRequest( return if (urlString.isAuthenticationUrl) { // return the request with "Authorization" header if the url is a Authentication url. DownloadManager.Request(urlString.removeAuthenticationFromUrl.toUri()).apply { + setTitle(bookTitle) setDestinationUri(Uri.fromFile(getDestinationFile(sharedPreferenceUtil))) setAllowedNetworkTypes( if (sharedPreferenceUtil.prefWifiOnly) @@ -115,6 +116,7 @@ fun DownloadRequest.toDownloadManagerRequest( } else { // return the request for normal urls. DownloadManager.Request(uri).apply { + setTitle(bookTitle) setDestinationUri(Uri.fromFile(getDestinationFile(sharedPreferenceUtil))) setAllowedNetworkTypes( if (sharedPreferenceUtil.prefWifiOnly) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadRequest.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadRequest.kt index 24ec973856..5d28096058 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadRequest.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadRequest.kt @@ -22,7 +22,7 @@ import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil import org.kiwix.kiwixmobile.core.utils.StorageUtils import java.io.File -data class DownloadRequest(val urlString: String) { +data class DownloadRequest(val urlString: String, val bookTitle: String) { val uri: Uri get() = Uri.parse(urlString) From a1bbd1eec25059e618384b200775cd371a7a492f Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Mon, 16 Dec 2024 16:24:35 +0530 Subject: [PATCH 12/12] Improved the resuming of downloads. --- .../downloadManager/DownloadManagerMonitor.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 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 785c83cc8e..4496f37e86 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 @@ -479,7 +479,7 @@ class DownloadManagerMonitor @Inject constructor( // due to some reason. Error.PAUSED_UNKNOWN, Error.WAITING_TO_RETRY -> { - resumeDownload(downloadRoomEntity.downloadId) + resumeDownload(downloadRoomEntity.downloadId, shouldUpdateStatus = false) false } @@ -499,7 +499,7 @@ class DownloadManagerMonitor @Inject constructor( * @return `true` to update the status and attempt to resume the download. */ private fun handleRetryablePausedDownload(downloadRoomEntity: DownloadRoomEntity): Boolean { - resumeDownload(downloadRoomEntity.downloadId) + resumeDownload(downloadRoomEntity.downloadId, shouldUpdateStatus = false) return true } @@ -519,7 +519,10 @@ class DownloadManagerMonitor @Inject constructor( } } - fun resumeDownload(downloadId: Long) { + fun resumeDownload( + downloadId: Long, + shouldUpdateStatus: Boolean = true + ) { synchronized(lock) { updater.onNext { if (pauseResumeDownloadInDownloadManagerContentResolver( @@ -528,8 +531,10 @@ class DownloadManagerMonitor @Inject constructor( STATUS_RUNNING ) ) { - // pass false when user resumed the download to proceed with further checks. - updateDownloadStatus(downloadId, Status.QUEUED, Error.NONE, pausedByUser = false) + if (shouldUpdateStatus) { + // pass false when user resumed the download to proceed with further checks. + updateDownloadStatus(downloadId, Status.QUEUED, Error.NONE, pausedByUser = false) + } } } }