From 8df7bddff0c0824a59c41217dafabfb51d9f4508 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Tue, 10 Dec 2024 16:50:09 +0530 Subject: [PATCH 01/13] 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 4d4120215..4b6e685fd 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 403db07df..4789dec5d 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 9444a3d74b773b109e91e9bc8a284919e046a721 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Tue, 10 Dec 2024 16:54:37 +0530 Subject: [PATCH 02/13] 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 d3bc7d308..a028247d2 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 6330a78df26cd3138e864d2c83aef7ad0f34ab12 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Tue, 10 Dec 2024 17:36:46 +0530 Subject: [PATCH 03/13] 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 4789dec5d..275fc1717 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 7c7dc2fcbb29f1d4f92aff485d4350fe1def81ed Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Tue, 10 Dec 2024 18:58:42 +0530 Subject: [PATCH 04/13] 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 4b6e685fd..b112d4abe 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 275fc1717..1464c0e47 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 136dc3cef01c7a185e7a6d2660e21cd25c80f609 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Thu, 12 Dec 2024 15:37:45 +0530 Subject: [PATCH 05/13] 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 1a049bae4..9ce3e93ea 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 58a89a6c7..a5561b578 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 b112d4abe..787c75c40 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 1464c0e47..f539ed46b 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 e76caf884..d9db90c59 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 a17ccefd892d18b3bcd31cd6b086584f8df0faad Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Thu, 12 Dec 2024 15:50:14 +0530 Subject: [PATCH 06/13] 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 9999f9ca4..732a77be8 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 5a28af4d6e859ddf5cf8eec7aec994f95a410685 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Thu, 12 Dec 2024 16:41:47 +0530 Subject: [PATCH 07/13] 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 9ce3e93ea..dff17d874 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 a8fae21a4..e6955b275 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 6fcf5196058dc4b96c62936e37c3e5356009cfbd Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Thu, 12 Dec 2024 16:47:06 +0530 Subject: [PATCH 08/13] 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 787c75c40..fc633d2b2 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 f539ed46b..19f76b871 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 092d060fb325badaeb9566f75846676d315a72c2 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Mon, 16 Dec 2024 14:56:15 +0530 Subject: [PATCH 09/13] 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 | 587 +++++++++++--- .../DownloadManagerRequester.kt | 6 +- .../downloadManager/DownloadMonitorService.kt | 744 ------------------ .../DownloadNotificationManager.kt | 259 ------ .../DownloadNotificationModel.kt | 46 -- 9 files changed, 496 insertions(+), 1178 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 07fe617ea..34244d904 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 27e559a84..bec1c4c33 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 f85bf4e09..a599aa436 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 fc633d2b2..8e0964c1f 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( ) } - /** - * Determines if the DownloadMonitorService should be started. - * Checks if there are active downloads and if the service is not already running. - */ - private fun shouldStartDownloadMonitorService(): Boolean = - getActiveDownloads().isNotEmpty() && - !context.isServiceRunning(DownloadMonitorService::class.java) - - 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(context) && - !download.pausedByUser + @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()) + } + } + } } - /** - * 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) { + @SuppressLint("Range") + fun queryDownloadStatus(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" - ) + 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 } } } } } - private fun startDownloadMonitorService() { - context.startService(Intent(context, DownloadMonitorService::class.java)) + /** + * 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(context) && + !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 } fun pauseDownload(downloadId: Long) { - context.startService(getDownloadMonitorIntent(ACTION_PAUSE, downloadId.toInt())) - startMonitoringDownloads() + 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) + } + } + } } 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 25f9e6685..d300105af 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 19f76b871..000000000 --- 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 e7c8600ef..000000000 --- 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 17f2de2c9..000000000 --- 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 86658fb58e9a26ecc8b615c2a5d59840585f1d16 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Mon, 16 Dec 2024 15:19:27 +0530 Subject: [PATCH 10/13] 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 8e0964c1f..3191c0d79 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 5050e4a92eceb6084b2fd64572840f533e4a6ba0 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Mon, 16 Dec 2024 15:40:53 +0530 Subject: [PATCH 11/13] 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 6f21298c1..0d4aafcbd 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 3191c0d79..785c83cc8 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 d300105af..616560f16 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 24ec97385..5d2809605 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 9b8abe5ab25917f0b663a578216c45b4772fd87f Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Mon, 16 Dec 2024 16:24:35 +0530 Subject: [PATCH 12/13] 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 785c83cc8..4496f37e8 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) + } } } } From 1f55d953868c20cf52901319175606717537f746 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Wed, 18 Dec 2024 13:16:26 +0530 Subject: [PATCH 13/13] Improved the resuming of downloads. * Improved the resuming download logic. --- .../downloadManager/DownloadManagerMonitor.kt | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 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 4496f37e8..6f7f01c25 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 @@ -20,7 +20,6 @@ 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 @@ -562,9 +561,13 @@ class DownloadManagerMonitor @Inject constructor( put(COLUMN_CONTROL, control) put(DownloadManager.COLUMN_STATUS, status) } - val uri = ContentUris.withAppendedId(downloadBaseUri, downloadId) context.contentResolver - .update(uri, contentValues, null, null) + .update( + downloadBaseUri, + contentValues, + getWhereClauseForIds(longArrayOf(downloadId)), + getWhereArgsForIds(longArrayOf(downloadId)) + ) true } catch (ignore: Exception) { Log.e("DOWNLOAD_MONITOR", "Couldn't pause/resume the download. Original exception = $ignore") @@ -572,6 +575,33 @@ class DownloadManagerMonitor @Inject constructor( } } + private fun getWhereArgsForIds(ids: LongArray): Array { + val whereArgs = arrayOfNulls(ids.size) + return getWhereArgsForIds(ids, whereArgs) + } + + private fun getWhereArgsForIds(ids: LongArray, args: Array): Array { + assert(args.size >= ids.size) + for (i in ids.indices) { + args[i] = "${ids[i]}" + } + return args + } + + private fun getWhereClauseForIds(ids: LongArray): String { + val whereClause = StringBuilder() + whereClause.append("(") + for (i in ids.indices) { + if (i > 0) { + whereClause.append("OR ") + } + whereClause.append("_id") + whereClause.append(" = ? ") + } + whereClause.append(")") + return "$whereClause" + } + private fun shouldUpdateDownloadStatus(downloadRoomEntity: DownloadRoomEntity) = downloadRoomEntity.status != Status.COMPLETED