From 1f62caebbecf93260bebd3e164bc73fe5ffe26ce Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Mon, 25 Nov 2024 20:48:27 +0530 Subject: [PATCH 1/4] Fixed: Download notification was disappearing when the application is in background. --- .../kiwixmobile/di/modules/ServiceModule.kt | 14 + core/src/main/AndroidManifest.xml | 3 + .../core/di/components/CoreComponent.kt | 3 - .../di/components/CoreServiceComponent.kt | 2 + .../core/di/modules/DownloaderModule.kt | 8 - .../downloadManager/DownloadManagerMonitor.kt | 530 ++------------- .../DownloadManagerRequester.kt | 7 +- .../downloadManager/DownloadMonitorService.kt | 603 ++++++++++++++++++ .../DownloadMonitorServiceCallback.kt | 23 + ...oadNotificationActionsBroadcastReceiver.kt | 17 +- .../DownloadNotificationManager.kt | 111 ++-- .../kiwixmobile/core/main/CoreMainActivity.kt | 7 - 12 files changed, 771 insertions(+), 557 deletions(-) create mode 100644 core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt create mode 100644 core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorServiceCallback.kt diff --git a/app/src/main/java/org/kiwix/kiwixmobile/di/modules/ServiceModule.kt b/app/src/main/java/org/kiwix/kiwixmobile/di/modules/ServiceModule.kt index a92e0ccc2..b103e0ab8 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/di/modules/ServiceModule.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/di/modules/ServiceModule.kt @@ -23,6 +23,7 @@ import android.app.Service import android.content.Context import dagger.Module import dagger.Provides +import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationActionsBroadcastReceiver import org.kiwix.kiwixmobile.core.qr.GenerateQR import org.kiwix.kiwixmobile.core.read_aloud.ReadAloudNotificationManger import org.kiwix.kiwixmobile.di.ServiceScope @@ -75,4 +76,17 @@ class ServiceModule { @Provides @ServiceScope fun providesGenerateQr(): GenerateQR = GenerateQR() + + @Provides + @ServiceScope + fun providesDownloadNotificationActionsBroadcastReceiverCallback(service: Service): + DownloadNotificationActionsBroadcastReceiver.Callback = + service as DownloadNotificationActionsBroadcastReceiver.Callback + + @Provides + @ServiceScope + fun providesDownloadNotificationActionsBroadcastReceiver( + callBack: DownloadNotificationActionsBroadcastReceiver.Callback + ): DownloadNotificationActionsBroadcastReceiver = + DownloadNotificationActionsBroadcastReceiver(callBack) } diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml index ab9593b5d..2cf14d2eb 100644 --- a/core/src/main/AndroidManifest.xml +++ b/core/src/main/AndroidManifest.xml @@ -90,5 +90,8 @@ android:name=".error.DiagnosticReportActivity" android:exported="false" /> + diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/di/components/CoreComponent.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/di/components/CoreComponent.kt index 93d305c05..d8aefd9d4 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/di/components/CoreComponent.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/di/components/CoreComponent.kt @@ -52,7 +52,6 @@ import org.kiwix.kiwixmobile.core.di.modules.NetworkModule import org.kiwix.kiwixmobile.core.di.modules.SearchModule import org.kiwix.kiwixmobile.core.downloader.Downloader import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadManagerBroadcastReceiver -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationActionsBroadcastReceiver import org.kiwix.kiwixmobile.core.error.ErrorActivity import org.kiwix.kiwixmobile.core.main.KiwixWebView import org.kiwix.kiwixmobile.core.reader.ZimFileReader @@ -117,8 +116,6 @@ interface CoreComponent { fun mutex(): Mutex fun downloadManagerBroadCastReceiver(): DownloadManagerBroadcastReceiver - fun downloadNotificationActionBroadCastReceiver(): DownloadNotificationActionsBroadcastReceiver - fun inject(application: CoreApp) fun inject(kiwixWebView: KiwixWebView) fun inject(storageSelectDialog: StorageSelectDialog) 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 bec1c4c33..27e559a84 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,12 +23,14 @@ 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 a021134dc..f85bf4e09 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 @@ -30,7 +30,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.DownloadNotificationActionsBroadcastReceiver import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil import javax.inject.Singleton @@ -71,13 +70,6 @@ object DownloaderModule { callback: DownloadManagerBroadcastReceiver.Callback ): DownloadManagerBroadcastReceiver = DownloadManagerBroadcastReceiver(callback) - @Provides - @Singleton - fun providesDownloadNotificationActionsBroadcastReceiver( - downloadManagerMonitor: DownloadManagerMonitor - ): DownloadNotificationActionsBroadcastReceiver = - DownloadNotificationActionsBroadcastReceiver(downloadManagerMonitor) - @Provides @Singleton fun providesDownloadNotificationManager( 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 db975d6ff..dcf030af6 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,60 +18,50 @@ package org.kiwix.kiwixmobile.core.downloader.downloadManager -import android.annotation.SuppressLint import android.app.DownloadManager -import android.app.DownloadManager.COLUMN_STATUS -import android.content.ContentUris -import android.content.ContentValues +import android.content.ComponentName 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 android.content.ServiceConnection +import android.os.IBinder +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch 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.model.DownloadModel -import org.kiwix.kiwixmobile.core.downloader.model.DownloadState -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 DownloadManagerMonitor @Inject constructor( - private val downloadManager: DownloadManager, val downloadRoomDao: DownloadRoomDao, - private val context: Context, - private val downloadNotificationManager: DownloadNotificationManager -) : DownloadMonitor, DownloadManagerBroadcastReceiver.Callback { - - private val updater = PublishSubject.create<() -> Unit>() + private val context: Context +) : DownloadMonitor, DownloadManagerBroadcastReceiver.Callback, DownloadMonitorServiceCallback { private val lock = Any() - private val downloadInfoMap = mutableMapOf() - private var monitoringDisposable: Disposable? = null + private var downloadMonitorService: DownloadMonitorService? = null + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + downloadMonitorService = + (binder as? DownloadMonitorService.DownloadMonitorBinder)?.downloadMonitorService?.get() + downloadMonitorService?.registerCallback(this@DownloadManagerMonitor) + CoroutineScope(Dispatchers.IO).launch { + if (downloadRoomDao.downloads().blockingFirst().isNotEmpty()) { + startService() + } + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + downloadMonitorService = null + } + } init { - startMonitoringDownloads() - setupUpdater() + bindService() + } + + private fun bindService() { + val serviceIntent = Intent(context, DownloadMonitorService::class.java) + context.bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE) } override fun downloadCompleteOrCancelled(intent: Intent) { @@ -79,466 +69,44 @@ class DownloadManagerMonitor @Inject constructor( intent.extras?.let { val downloadId = it.getLong(DownloadManager.EXTRA_DOWNLOAD_ID, -1L) if (downloadId != -1L) { - queryDownloadStatus(downloadId) + downloadMonitorService?.queryDownloadStatus(downloadId) } } } } - /** - * 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()) - .observeOn(Schedulers.io()) - .subscribe( - { - try { - synchronized(lock) { - if (downloadRoomDao.downloads().blockingFirst().isNotEmpty()) { - checkDownloads() - } else { - // dispose to avoid unnecessary request to downloadManager - // when there is no download ongoing. - monitoringDisposable?.dispose() - } - } - } catch (ignore: Exception) { - Log.i( - "DOWNLOAD_MONITOR", - "Couldn't get the downloads update. Original exception = $ignore" - ) - } - }, - Throwable::printStackTrace - ) + bindService() + startService() + downloadMonitorService?.startMonitoringDownloads() } - @Suppress("CheckResult") - private fun setupUpdater() { - updater.subscribeOn(Schedulers.io()).observeOn(Schedulers.io()).subscribe( - { - synchronized(lock) { it.invoke() } - }, - 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") - private 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.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) - val totalBytes = cursor.getInt(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: Int, - totalBytes: Int - ) { - val error = mapDownloadError(reason) - updateDownloadStatus( - downloadId, - Status.FAILED, - error, - progress, - etaInMilliSeconds, - bytesDownloaded, - totalBytes - ) - } - - private fun handlePausedDownload( - downloadId: Long, - progress: Int, - bytesDownloaded: Int, - totalSizeOfDownload: Int, - 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: Int, - totalSizeOfDownload: Int - ) { - 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: Int, totalBytes: Int): Int = - if (totalBytes > ZERO) { - (bytesDownloaded / totalBytes.toDouble()).times(HUNDERED).toInt() - } else { - ZERO - } - - private fun calculateETA(downloadedFileId: Long, bytesDownloaded: Int, totalBytes: Int): 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: Int = DEFAULT_INT_VALUE, - totalSizeOfDownload: Int = DEFAULT_INT_VALUE - ) { - synchronized(lock) { - updater.onNext { - downloadRoomDao.getEntityForDownloadId(downloadId)?.let { downloadEntity -> - if (shouldUpdateDownloadStatus(downloadEntity)) { - val downloadModel = DownloadModel(downloadEntity).apply { - if (shouldUpdateDownloadStatus(status, error, downloadEntity)) { - state = status - } - this.error = error - if (progress > ZERO) { - this.progress = progress - } - this.etaInMilliSeconds = etaInMilliSeconds - if (bytesDownloaded != DEFAULT_INT_VALUE) { - this.bytesDownloaded = bytesDownloaded.toLong() - } - if (totalSizeOfDownload != DEFAULT_INT_VALUE) { - this.totalSizeOfDownload = totalSizeOfDownload.toLong() - } - } - downloadRoomDao.update(downloadModel) - updateNotification(downloadModel, downloadEntity.title, downloadEntity.description) - return@let - } - cancelNotification(downloadId) - } ?: run { - // already downloaded/cancelled so cancel the notification if any running. - cancelNotification(downloadId) - } - } - } - } - - /** - * 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". - * - * @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 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 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 - } - } - } - - private fun cancelNotification(downloadId: Long) { - downloadNotificationManager.cancelNotification(downloadId.toInt()) - } - - private fun updateNotification( - downloadModel: DownloadModel, - title: String, - description: String? - ) { - downloadNotificationManager.updateNotification( - 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(context).toString() - ) + private fun startService() { + ContextCompat.startForegroundService( + context, + Intent(context, DownloadMonitorService::class.java) ) } fun pauseDownload(downloadId: Long) { - synchronized(lock) { - updater.onNext { - if (pauseResumeDownloadInDownloadManagerContentResolver( - downloadId, - CONTROL_PAUSE, - STATUS_PAUSED_BY_APP - ) - ) { - updateDownloadStatus(downloadId, Status.PAUSED, Error.NONE) - } - } - } + downloadMonitorService?.pauseDownload(downloadId) } fun resumeDownload(downloadId: Long) { - synchronized(lock) { - updater.onNext { - if (pauseResumeDownloadInDownloadManagerContentResolver( - downloadId, - CONTROL_RUN, - STATUS_RUNNING - ) - ) { - updateDownloadStatus(downloadId, Status.QUEUED, Error.NONE) - } - } - } + downloadMonitorService?.resumeDownload(downloadId) } fun cancelDownload(downloadId: Long) { - synchronized(lock) { - downloadManager.remove(downloadId) - handleCancelledDownload(downloadId) - } + downloadMonitorService?.cancelDownload(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(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: Int -) + override fun onServiceDestroyed() { + downloadMonitorService?.registerCallback(null) + context.unbindService(serviceConnection) + downloadMonitorService = null + } +} 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..e8860f957 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 @@ -41,7 +41,12 @@ class DownloadManagerRequester @Inject constructor( private val downloadManagerMonitor: DownloadManagerMonitor ) : DownloadRequester { override fun enqueue(downloadRequest: DownloadRequest): Long = - downloadManager.enqueue(downloadRequest.toDownloadManagerRequest(sharedPreferenceUtil)) + downloadManager.enqueue(downloadRequest.toDownloadManagerRequest(sharedPreferenceUtil)).also { + Log.e( + "DOWNLOADING_STEP", + "enqueue: ${downloadRequest.toDownloadManagerRequest(sharedPreferenceUtil)}" + ) + } override fun onDownloadAdded() { // Start monitoring downloads after enqueuing a new download request. 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 new file mode 100644 index 000000000..abb170155 --- /dev/null +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt @@ -0,0 +1,603 @@ +/* + * 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.Binder +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.model.DownloadModel +import org.kiwix.kiwixmobile.core.downloader.model.DownloadState +import org.kiwix.kiwixmobile.core.utils.files.Log +import java.lang.ref.WeakReference +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(), DownloadNotificationActionsBroadcastReceiver.Callback { + + @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 val downloadMonitorBinder: IBinder = DownloadMonitorBinder(this) + private var downloadMonitorServiceCallback: DownloadMonitorServiceCallback? = null + private var isForeGroundServiceNotification: Boolean = true + + // @set:Inject + // var downloadNotificationBroadcastReceiver: DownloadNotificationActionsBroadcastReceiver? = null + + class DownloadMonitorBinder(downloadMonitorService: DownloadMonitorService) : Binder() { + val downloadMonitorService: WeakReference = + WeakReference(downloadMonitorService) + } + + override fun onBind(intent: Intent?): IBinder = downloadMonitorBinder + + override fun onCreate() { + CoreApp.coreComponent + .coreServiceComponent() + .service(this) + .build() + .inject(this) + super.onCreate() + setupUpdater() + startMonitoringDownloads() + // downloadNotificationBroadcastReceiver?.let(this::registerReceiver) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_NOT_STICKY + + fun registerCallback(downloadMonitorServiceCallback: DownloadMonitorServiceCallback?) { + this.downloadMonitorServiceCallback = downloadMonitorServiceCallback + } + + @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() { + Log.e("DOWNLOADING_STEP", "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 { + // dispose to avoid unnecessary request to downloadManager + // when there is no download ongoing. + stopMonitoringDownloads() + } + } + } catch (ignore: Exception) { + Log.i( + "DOWNLOAD_MONITOR", + "Couldn't get the downloads update. Original exception = $ignore" + ) + } + }, + Throwable::printStackTrace + ) + } + + @SuppressLint("Range") + private fun checkDownloads() { + synchronized(lock) { + Log.e("DOWNLOADING_STEP", "checkDownloads: 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 -> + Log.e("DOWNLOADING_STEP", "checkDownloads:") + 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.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) + val totalBytes = cursor.getInt(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: Int, + totalBytes: Int + ) { + val error = mapDownloadError(reason) + updateDownloadStatus( + downloadId, + Status.FAILED, + error, + progress, + etaInMilliSeconds, + bytesDownloaded, + totalBytes + ) + } + + private fun handlePausedDownload( + downloadId: Long, + progress: Int, + bytesDownloaded: Int, + totalSizeOfDownload: Int, + 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: Int, + totalSizeOfDownload: Int + ) { + 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: Int, totalBytes: Int): Int = + if (totalBytes > ZERO) { + (bytesDownloaded / totalBytes.toDouble()).times(HUNDERED).toInt() + } else { + ZERO + } + + private fun calculateETA(downloadedFileId: Long, bytesDownloaded: Int, totalBytes: Int): 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: Int = DEFAULT_INT_VALUE, + totalSizeOfDownload: Int = DEFAULT_INT_VALUE + ) { + synchronized(lock) { + updater.onNext { + Log.e("DOWNLOADING_STEP", "updateDownloadStatus:") + downloadRoomDao.getEntityForDownloadId(downloadId)?.let { downloadEntity -> + if (shouldUpdateDownloadStatus(downloadEntity)) { + Log.e("DOWNLOADING_STEP", "shouldUpdateDownloadStatus:") + val downloadModel = DownloadModel(downloadEntity).apply { + if (shouldUpdateDownloadStatus(status, error, downloadEntity)) { + state = status + } + this.error = error + if (progress > ZERO) { + this.progress = progress + } + this.etaInMilliSeconds = etaInMilliSeconds + if (bytesDownloaded != DEFAULT_INT_VALUE) { + this.bytesDownloaded = bytesDownloaded.toLong() + } + if (totalSizeOfDownload != DEFAULT_INT_VALUE) { + this.totalSizeOfDownload = totalSizeOfDownload.toLong() + } + } + downloadRoomDao.update(downloadModel) + Log.e("DOWNLOADING_STEP", "updateNotification: $downloadModel") + updateNotification(downloadModel, downloadEntity.title, downloadEntity.description) + return@let + } + cancelNotification(downloadId) + } ?: run { + // already downloaded/cancelled so cancel the notification if any running. + cancelNotification(downloadId) + } + } + } + } + + /** + * 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". + * + * @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 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 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 + } + } + } + + private fun cancelNotification(downloadId: Long) { + downloadNotificationManager.cancelNotification(downloadId.toInt()) + } + + private fun updateNotification( + downloadModel: DownloadModel, + title: String, + description: String? + ) { + val 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() + ) + val notification = downloadNotificationManager.createNotification(downloadNotificationModel) + if (isForeGroundServiceNotification) { + startForeground(downloadModel.downloadId.toInt(), notification) + isForeGroundServiceNotification = false + } else { + downloadNotificationManager.updateNotification(downloadNotificationModel) + } + } + + override fun pauseDownloads(downloadId: Long) { + pauseDownload(downloadId) + } + + override fun resumeDownloads(downloadId: Long) { + resumeDownload(downloadId) + } + + override fun cancelDownloads(downloadId: Long) { + cancelNotification(downloadId) + } + + fun pauseDownload(downloadId: Long) { + synchronized(lock) { + updater.onNext { + if (pauseResumeDownloadInDownloadManagerContentResolver( + downloadId, + CONTROL_PAUSE, + STATUS_PAUSED_BY_APP + ) + ) { + updateDownloadStatus(downloadId, Status.PAUSED, Error.NONE) + } + } + } + } + + fun resumeDownload(downloadId: Long) { + synchronized(lock) { + updater.onNext { + if (pauseResumeDownloadInDownloadManagerContentResolver( + downloadId, + CONTROL_RUN, + STATUS_RUNNING + ) + ) { + updateDownloadStatus(downloadId, Status.QUEUED, Error.NONE) + } + } + } + } + + fun cancelDownload(downloadId: Long) { + synchronized(lock) { + 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() { + // downloadNotificationBroadcastReceiver?.let(::unregisterReceiver) + monitoringDisposable?.dispose() + super.onDestroy() + } + + private fun stopMonitoringDownloads() { + Log.e("DOWNLOADING_STEP", "stopMonitoringDownloads: ") + isForeGroundServiceNotification = true + monitoringDisposable?.dispose() + downloadMonitorServiceCallback?.onServiceDestroyed() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + companion object { + private const val CHANNEL_ID = "download_monitor_channel" + private const val ONGOING_NOTIFICATION_ID = 1 + } +} + +data class DownloadInfo( + var startTime: Long, + var initialBytesDownloaded: Int +) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorServiceCallback.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorServiceCallback.kt new file mode 100644 index 000000000..f9ac92707 --- /dev/null +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorServiceCallback.kt @@ -0,0 +1,23 @@ +/* + * 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 + +interface DownloadMonitorServiceCallback { + fun onServiceDestroyed() +} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationActionsBroadcastReceiver.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationActionsBroadcastReceiver.kt index eb0618b07..56f9966a6 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationActionsBroadcastReceiver.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationActionsBroadcastReceiver.kt @@ -31,8 +31,9 @@ import javax.inject.Inject const val DOWNLOAD_NOTIFICATION_ACTION = "org.kiwix.kiwixmobile.download_notification_action" class DownloadNotificationActionsBroadcastReceiver @Inject constructor( - private val downloadManagerMonitor: DownloadManagerMonitor -) : BaseBroadcastReceiver() { + private val callback: Callback +) : + BaseBroadcastReceiver() { override val action: String = DOWNLOAD_NOTIFICATION_ACTION override fun onIntentWithActionReceived(context: Context, intent: Intent) { @@ -40,10 +41,16 @@ class DownloadNotificationActionsBroadcastReceiver @Inject constructor( val notificationAction = intent.getStringExtra(NOTIFICATION_ACTION) if (downloadId != -1) { when (notificationAction) { - ACTION_PAUSE -> downloadManagerMonitor.pauseDownload(downloadId.toLong()) - ACTION_RESUME -> downloadManagerMonitor.resumeDownload(downloadId.toLong()) - ACTION_CANCEL -> downloadManagerMonitor.cancelDownload(downloadId.toLong()) + ACTION_PAUSE -> callback.pauseDownloads(downloadId.toLong()) + ACTION_RESUME -> callback.resumeDownloads(downloadId.toLong()) + ACTION_CANCEL -> callback.cancelDownloads(downloadId.toLong()) } } } + + interface Callback { + fun pauseDownloads(downloadId: Long) + fun resumeDownloads(downloadId: Long) + fun cancelDownloads(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 index 8b08baba8..459489e7b 100644 --- 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 @@ -19,6 +19,7 @@ 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 @@ -57,59 +58,9 @@ class DownloadNotificationManager @Inject constructor( ) { synchronized(downloadNotificationsBuilderMap) { if (shouldUpdateNotification(downloadNotificationModel)) { - 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) notificationManager.notify( downloadNotificationModel.downloadId, - notificationBuilder.build() + createNotification(downloadNotificationModel) ) } else { // the download is cancelled/paused so remove the notification. @@ -118,6 +69,62 @@ class DownloadNotificationManager @Inject constructor( } } + 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) @@ -203,7 +210,7 @@ class DownloadNotificationManager @Inject constructor( NotificationChannel( DOWNLOAD_NOTIFICATION_CHANNEL_ID, context.getString(R.string.download_notification_channel_name), - NotificationManager.IMPORTANCE_DEFAULT + NotificationManager.IMPORTANCE_HIGH ).apply { setSound(null, null) enableVibration(false) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreMainActivity.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreMainActivity.kt index d36def353..27d016fee 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreMainActivity.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreMainActivity.kt @@ -51,7 +51,6 @@ import org.kiwix.kiwixmobile.core.data.remote.ObjectBoxToLibkiwixMigrator import org.kiwix.kiwixmobile.core.data.remote.ObjectBoxToRoomMigrator import org.kiwix.kiwixmobile.core.di.components.CoreActivityComponent import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadManagerBroadcastReceiver -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationActionsBroadcastReceiver import org.kiwix.kiwixmobile.core.error.ErrorActivity import org.kiwix.kiwixmobile.core.extensions.browserIntent import org.kiwix.kiwixmobile.core.extensions.getToolbarNavigationIcon @@ -101,10 +100,6 @@ abstract class CoreMainActivity : BaseActivity(), WebViewProvider { @Inject lateinit var downloadManagerBroadcastReceiver: DownloadManagerBroadcastReceiver - - @Inject - lateinit var downloadNotificationActionsReceiver: DownloadNotificationActionsBroadcastReceiver - override fun onCreate(savedInstanceState: Bundle?) { setTheme(R.style.KiwixTheme) super.onCreate(savedInstanceState) @@ -136,7 +131,6 @@ abstract class CoreMainActivity : BaseActivity(), WebViewProvider { objectBoxToRoomMigrator.migrateObjectBoxDataToRoom() } downloadManagerBroadcastReceiver.let(::registerReceiver) - downloadNotificationActionsReceiver.let(::registerReceiver) createApplicationShortcuts() } @@ -156,7 +150,6 @@ abstract class CoreMainActivity : BaseActivity(), WebViewProvider { override fun onDestroy() { downloadManagerBroadcastReceiver.let(::unregisterReceiver) - downloadNotificationActionsReceiver.let(::unregisterReceiver) super.onDestroy() } From da9528b8ffec9cc043db7302929f8d32e2cc218a Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Tue, 26 Nov 2024 19:43:16 +0530 Subject: [PATCH 2/4] Fixed: the download notification controls was not working. * Refactored/cleanup code. * Added foreground permission in core module so that it can be used in both modules. --- app/src/main/AndroidManifest.xml | 3 - .../kiwixmobile/di/modules/ServiceModule.kt | 21 --- core/src/main/AndroidManifest.xml | 3 + .../core/di/modules/NetworkModule.kt | 4 +- .../downloadManager/DownloadManagerMonitor.kt | 74 ++++----- .../DownloadManagerRequester.kt | 7 +- .../downloadManager/DownloadMonitorService.kt | 142 ++++++++++-------- .../DownloadMonitorServiceCallback.kt | 23 --- ...oadNotificationActionsBroadcastReceiver.kt | 56 ------- .../DownloadNotificationManager.kt | 16 +- 10 files changed, 128 insertions(+), 221 deletions(-) delete mode 100644 core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorServiceCallback.kt delete mode 100644 core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationActionsBroadcastReceiver.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index db386c9af..07fe617ea 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,9 +8,6 @@ tools:ignore="CoarseFineLocation" /> - - - + + + diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/di/modules/NetworkModule.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/di/modules/NetworkModule.kt index 4d14c2ad2..909fd94ac 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/di/modules/NetworkModule.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/di/modules/NetworkModule.kt @@ -35,8 +35,8 @@ const val CONNECTION_TIMEOUT = 10L // increase the read and call timeout since the content is 19MB large so it takes // more time to read on slow internet connection, and due to less read timeout // the request is canceled. -const val READ_TIMEOUT = 180L -const val CALL_TIMEOUT = 180L +const val READ_TIMEOUT = 300L +const val CALL_TIMEOUT = 300L const val USER_AGENT = "kiwix-android-version:${BuildConfig.VERSION_CODE}" const val KIWIX_DOWNLOAD_URL = "https://mirror.download.kiwix.org/" 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 dcf030af6..370b65d7d 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 @@ -19,94 +19,82 @@ package org.kiwix.kiwixmobile.core.downloader.downloadManager import android.app.DownloadManager -import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.ServiceConnection -import android.os.IBinder -import androidx.core.content.ContextCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch 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 javax.inject.Inject class DownloadManagerMonitor @Inject constructor( val downloadRoomDao: DownloadRoomDao, private val context: Context -) : DownloadMonitor, DownloadManagerBroadcastReceiver.Callback, DownloadMonitorServiceCallback { +) : DownloadMonitor, DownloadManagerBroadcastReceiver.Callback { private val lock = Any() - private var downloadMonitorService: DownloadMonitorService? = null - private val serviceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { - downloadMonitorService = - (binder as? DownloadMonitorService.DownloadMonitorBinder)?.downloadMonitorService?.get() - downloadMonitorService?.registerCallback(this@DownloadManagerMonitor) - CoroutineScope(Dispatchers.IO).launch { - if (downloadRoomDao.downloads().blockingFirst().isNotEmpty()) { - startService() - } - } - } - - override fun onServiceDisconnected(name: ComponentName?) { - downloadMonitorService = null - } - } init { - bindService() + CoroutineScope(Dispatchers.IO).launch { + if (getActiveDownloads().isNotEmpty()) { + startService() + } + } } - private fun bindService() { - val serviceIntent = Intent(context, DownloadMonitorService::class.java) - context.bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE) - } + private suspend fun getActiveDownloads(): List = + downloadRoomDao.downloadRoomEntity().blockingFirst().filter { + it.status != Status.PAUSED && 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) { - downloadMonitorService?.queryDownloadStatus(downloadId) + context.startService( + getDownloadMonitorIntent( + ACTION_QUERY_DOWNLOAD_STATUS, + downloadId.toInt() + ) + ) } } } } fun startMonitoringDownloads() { - bindService() startService() - downloadMonitorService?.startMonitoringDownloads() } private fun startService() { - ContextCompat.startForegroundService( - context, - Intent(context, DownloadMonitorService::class.java) - ) + context.startService(Intent(context, DownloadMonitorService::class.java)) } fun pauseDownload(downloadId: Long) { - downloadMonitorService?.pauseDownload(downloadId) + context.startService(getDownloadMonitorIntent(ACTION_PAUSE, downloadId.toInt())) } fun resumeDownload(downloadId: Long) { - downloadMonitorService?.resumeDownload(downloadId) + context.startService(getDownloadMonitorIntent(ACTION_RESUME, downloadId.toInt())) } fun cancelDownload(downloadId: Long) { - downloadMonitorService?.cancelDownload(downloadId) + context.startService(getDownloadMonitorIntent(ACTION_CANCEL, downloadId.toInt())) } + 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) + } + override fun init() { // empty method to so class does not get reported unused } - - override fun onServiceDestroyed() { - downloadMonitorService?.registerCallback(null) - context.unbindService(serviceConnection) - downloadMonitorService = null - } } 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 e8860f957..25f9e6685 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 @@ -41,12 +41,7 @@ class DownloadManagerRequester @Inject constructor( private val downloadManagerMonitor: DownloadManagerMonitor ) : DownloadRequester { override fun enqueue(downloadRequest: DownloadRequest): Long = - downloadManager.enqueue(downloadRequest.toDownloadManagerRequest(sharedPreferenceUtil)).also { - Log.e( - "DOWNLOADING_STEP", - "enqueue: ${downloadRequest.toDownloadManagerRequest(sharedPreferenceUtil)}" - ) - } + downloadManager.enqueue(downloadRequest.toDownloadManagerRequest(sharedPreferenceUtil)) override fun onDownloadAdded() { // Start monitoring downloads after enqueuing a new download request. 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 abb170155..385d97d54 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 @@ -26,7 +26,6 @@ import android.content.ContentValues import android.content.Intent import android.database.Cursor import android.net.Uri -import android.os.Binder import android.os.IBinder import io.reactivex.Observable import io.reactivex.disposables.Disposable @@ -35,10 +34,13 @@ 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.files.Log -import java.lang.ref.WeakReference import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -59,7 +61,7 @@ const val STATUS_PAUSED_BY_APP = 193 const val COLUMN_CONTROL = "control" val downloadBaseUri: Uri = Uri.parse("content://downloads/my_downloads") -class DownloadMonitorService : Service(), DownloadNotificationActionsBroadcastReceiver.Callback { +class DownloadMonitorService : Service() { @Inject lateinit var downloadManager: DownloadManager @@ -73,19 +75,9 @@ class DownloadMonitorService : Service(), DownloadNotificationActionsBroadcastRe private var monitoringDisposable: Disposable? = null private val downloadInfoMap = mutableMapOf() private val updater = PublishSubject.create<() -> Unit>() - private val downloadMonitorBinder: IBinder = DownloadMonitorBinder(this) - private var downloadMonitorServiceCallback: DownloadMonitorServiceCallback? = null - private var isForeGroundServiceNotification: Boolean = true + private var foreGroundServiceInformation: Pair = true to DEFAULT_INT_VALUE - // @set:Inject - // var downloadNotificationBroadcastReceiver: DownloadNotificationActionsBroadcastReceiver? = null - - class DownloadMonitorBinder(downloadMonitorService: DownloadMonitorService) : Binder() { - val downloadMonitorService: WeakReference = - WeakReference(downloadMonitorService) - } - - override fun onBind(intent: Intent?): IBinder = downloadMonitorBinder + override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { CoreApp.coreComponent @@ -96,13 +88,22 @@ class DownloadMonitorService : Service(), DownloadNotificationActionsBroadcastRe super.onCreate() setupUpdater() startMonitoringDownloads() - // downloadNotificationBroadcastReceiver?.let(this::registerReceiver) } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_NOT_STICKY - - fun registerCallback(downloadMonitorServiceCallback: DownloadMonitorServiceCallback?) { - this.downloadMonitorServiceCallback = downloadMonitorServiceCallback + 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 -> queryDownloadStatus(downloadId.toLong()) + } + } + return START_NOT_STICKY } @Suppress("CheckResult") @@ -122,8 +123,7 @@ class DownloadMonitorService : Service(), DownloadNotificationActionsBroadcastRe * when there are no ongoing downloads to avoid unnecessary resource usage. */ @Suppress("MagicNumber") - fun startMonitoringDownloads() { - Log.e("DOWNLOADING_STEP", "startMonitoringDownloads:") + 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) @@ -142,7 +142,7 @@ class DownloadMonitorService : Service(), DownloadNotificationActionsBroadcastRe } } } catch (ignore: Exception) { - Log.i( + Log.e( "DOWNLOAD_MONITOR", "Couldn't get the downloads update. Original exception = $ignore" ) @@ -155,7 +155,6 @@ class DownloadMonitorService : Service(), DownloadNotificationActionsBroadcastRe @SuppressLint("Range") private fun checkDownloads() { synchronized(lock) { - Log.e("DOWNLOADING_STEP", "checkDownloads: lock") val query = DownloadManager.Query().setFilterByStatus( DownloadManager.STATUS_RUNNING or DownloadManager.STATUS_PAUSED or @@ -163,7 +162,6 @@ class DownloadMonitorService : Service(), DownloadNotificationActionsBroadcastRe DownloadManager.STATUS_SUCCESSFUL ) downloadManager.query(query).use { cursor -> - Log.e("DOWNLOADING_STEP", "checkDownloads:") if (cursor.moveToFirst()) { do { val downloadId = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_ID)) @@ -385,10 +383,8 @@ class DownloadMonitorService : Service(), DownloadNotificationActionsBroadcastRe ) { synchronized(lock) { updater.onNext { - Log.e("DOWNLOADING_STEP", "updateDownloadStatus:") downloadRoomDao.getEntityForDownloadId(downloadId)?.let { downloadEntity -> if (shouldUpdateDownloadStatus(downloadEntity)) { - Log.e("DOWNLOADING_STEP", "shouldUpdateDownloadStatus:") val downloadModel = DownloadModel(downloadEntity).apply { if (shouldUpdateDownloadStatus(status, error, downloadEntity)) { state = status @@ -406,7 +402,6 @@ class DownloadMonitorService : Service(), DownloadNotificationActionsBroadcastRe } } downloadRoomDao.update(downloadModel) - Log.e("DOWNLOADING_STEP", "updateNotification: $downloadModel") updateNotification(downloadModel, downloadEntity.title, downloadEntity.description) return@let } @@ -472,14 +467,63 @@ class DownloadMonitorService : Service(), DownloadNotificationActionsBroadcastRe private fun cancelNotification(downloadId: Long) { downloadNotificationManager.cancelNotification(downloadId.toInt()) + assignNewForegroundNotification() } + private fun assignNewForegroundNotification() { + 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 + stopMonitoringDownloads() + } + } + + private fun getActiveDownloads(): List = + downloadRoomDao.downloadRoomEntity().blockingFirst().filter { + it.status != Status.PAUSED && it.status != Status.CANCELLED + } + private fun updateNotification( downloadModel: DownloadModel, title: String, description: String? ) { - val downloadNotificationModel = DownloadNotificationModel( + 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) { + cancelNotification(downloadId) + } + } + ) + } + } + + private fun getDownloadNotificationModel( + downloadModel: DownloadModel, + title: String, + description: String? + ): DownloadNotificationModel = + DownloadNotificationModel( downloadId = downloadModel.downloadId.toInt(), status = downloadModel.state, progress = downloadModel.progress, @@ -493,28 +537,8 @@ class DownloadMonitorService : Service(), DownloadNotificationActionsBroadcastRe downloadModel.book.url ).toReadableState(this).toString() ) - val notification = downloadNotificationManager.createNotification(downloadNotificationModel) - if (isForeGroundServiceNotification) { - startForeground(downloadModel.downloadId.toInt(), notification) - isForeGroundServiceNotification = false - } else { - downloadNotificationManager.updateNotification(downloadNotificationModel) - } - } - override fun pauseDownloads(downloadId: Long) { - pauseDownload(downloadId) - } - - override fun resumeDownloads(downloadId: Long) { - resumeDownload(downloadId) - } - - override fun cancelDownloads(downloadId: Long) { - cancelNotification(downloadId) - } - - fun pauseDownload(downloadId: Long) { + private fun pauseDownload(downloadId: Long) { synchronized(lock) { updater.onNext { if (pauseResumeDownloadInDownloadManagerContentResolver( @@ -529,7 +553,7 @@ class DownloadMonitorService : Service(), DownloadNotificationActionsBroadcastRe } } - fun resumeDownload(downloadId: Long) { + private fun resumeDownload(downloadId: Long) { synchronized(lock) { updater.onNext { if (pauseResumeDownloadInDownloadManagerContentResolver( @@ -544,7 +568,7 @@ class DownloadMonitorService : Service(), DownloadNotificationActionsBroadcastRe } } - fun cancelDownload(downloadId: Long) { + private fun cancelDownload(downloadId: Long) { synchronized(lock) { downloadManager.remove(downloadId) handleCancelledDownload(downloadId) @@ -577,27 +601,23 @@ class DownloadMonitorService : Service(), DownloadNotificationActionsBroadcastRe downloadRoomEntity.status != Status.COMPLETED override fun onDestroy() { - // downloadNotificationBroadcastReceiver?.let(::unregisterReceiver) monitoringDisposable?.dispose() super.onDestroy() } private fun stopMonitoringDownloads() { - Log.e("DOWNLOADING_STEP", "stopMonitoringDownloads: ") - isForeGroundServiceNotification = true + foreGroundServiceInformation = true to DEFAULT_INT_VALUE monitoringDisposable?.dispose() - downloadMonitorServiceCallback?.onServiceDestroyed() stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } - - companion object { - private const val CHANNEL_ID = "download_monitor_channel" - private const val ONGOING_NOTIFICATION_ID = 1 - } } data class DownloadInfo( var startTime: Long, var initialBytesDownloaded: Int ) + +interface AssignNewForegroundServiceNotification { + fun assignNewForegroundServiceNotification(downloadId: Long) +} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorServiceCallback.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorServiceCallback.kt deleted file mode 100644 index f9ac92707..000000000 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorServiceCallback.kt +++ /dev/null @@ -1,23 +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 - -interface DownloadMonitorServiceCallback { - fun onServiceDestroyed() -} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationActionsBroadcastReceiver.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationActionsBroadcastReceiver.kt deleted file mode 100644 index 56f9966a6..000000000 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationActionsBroadcastReceiver.kt +++ /dev/null @@ -1,56 +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.content.Context -import android.content.Intent -import org.kiwix.kiwixmobile.core.base.BaseBroadcastReceiver -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_RESUME -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager.Companion.EXTRA_DOWNLOAD_ID -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager.Companion.NOTIFICATION_ACTION -import javax.inject.Inject - -const val DOWNLOAD_NOTIFICATION_ACTION = "org.kiwix.kiwixmobile.download_notification_action" - -class DownloadNotificationActionsBroadcastReceiver @Inject constructor( - private val callback: Callback -) : - BaseBroadcastReceiver() { - - override val action: String = DOWNLOAD_NOTIFICATION_ACTION - override fun onIntentWithActionReceived(context: Context, intent: Intent) { - val downloadId = intent.getIntExtra(EXTRA_DOWNLOAD_ID, -1) - val notificationAction = intent.getStringExtra(NOTIFICATION_ACTION) - if (downloadId != -1) { - when (notificationAction) { - ACTION_PAUSE -> callback.pauseDownloads(downloadId.toLong()) - ACTION_RESUME -> callback.resumeDownloads(downloadId.toLong()) - ACTION_CANCEL -> callback.cancelDownloads(downloadId.toLong()) - } - } - } - - interface Callback { - fun pauseDownloads(downloadId: Long) - fun resumeDownloads(downloadId: Long) - fun cancelDownloads(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 index 459489e7b..e7c8600ef 100644 --- 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 @@ -54,7 +54,8 @@ class DownloadNotificationManager @Inject constructor( } fun updateNotification( - downloadNotificationModel: DownloadNotificationModel + downloadNotificationModel: DownloadNotificationModel, + assignNewForegroundServiceNotification: AssignNewForegroundServiceNotification ) { synchronized(downloadNotificationsBuilderMap) { if (shouldUpdateNotification(downloadNotificationModel)) { @@ -64,7 +65,9 @@ class DownloadNotificationManager @Inject constructor( ) } else { // the download is cancelled/paused so remove the notification. - cancelNotification(downloadNotificationModel.downloadId) + assignNewForegroundServiceNotification.assignNewForegroundServiceNotification( + downloadNotificationModel.downloadId.toLong() + ) } } } @@ -191,16 +194,16 @@ class DownloadNotificationManager @Inject constructor( } private fun getActionPendingIntent(action: String, downloadId: Int): PendingIntent { - val intent = - Intent(DOWNLOAD_NOTIFICATION_ACTION).apply { + val pendingIntent = + Intent(context, DownloadMonitorService::class.java).apply { putExtra(NOTIFICATION_ACTION, action) putExtra(EXTRA_DOWNLOAD_ID, downloadId) } val requestCode = downloadId + action.hashCode() - return PendingIntent.getBroadcast( + return PendingIntent.getService( context, requestCode, - intent, + pendingIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) } @@ -250,6 +253,7 @@ class DownloadNotificationManager @Inject constructor( 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" } } From 933658c10c2b53ddb54a232233882c64e250cdc0 Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Tue, 26 Nov 2024 20:14:16 +0530 Subject: [PATCH 3/4] Improved method naming. --- .../downloadManager/DownloadMonitorService.kt | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt index 385d97d54..83211007b 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 @@ -136,9 +136,7 @@ class DownloadMonitorService : Service() { if (downloadRoomDao.downloads().blockingFirst().isNotEmpty()) { checkDownloads() } else { - // dispose to avoid unnecessary request to downloadManager - // when there is no download ongoing. - stopMonitoringDownloads() + stopForegroundServiceForDownloads() } } } catch (ignore: Exception) { @@ -405,10 +403,11 @@ class DownloadMonitorService : Service() { updateNotification(downloadModel, downloadEntity.title, downloadEntity.description) return@let } - cancelNotification(downloadId) + cancelNotificationAndAssignNewNotificationToForegroundService(downloadId) } ?: run { - // already downloaded/cancelled so cancel the notification if any running. - cancelNotification(downloadId) + // already downloaded/cancelled so cancel the notification if any running, and + // assign new notification to foreground service. + cancelNotificationAndAssignNewNotificationToForegroundService(downloadId) } } } @@ -465,12 +464,12 @@ class DownloadMonitorService : Service() { } } - private fun cancelNotification(downloadId: Long) { + private fun cancelNotificationAndAssignNewNotificationToForegroundService(downloadId: Long) { downloadNotificationManager.cancelNotification(downloadId.toInt()) - assignNewForegroundNotification() + updateForegroundNotificationOrStopService() } - private fun assignNewForegroundNotification() { + private fun updateForegroundNotificationOrStopService() { val activeDownloads = getActiveDownloads() if (activeDownloads.isNotEmpty()) { // Promote the first active download to foreground @@ -487,7 +486,7 @@ class DownloadMonitorService : Service() { startForeground(foreGroundServiceInformation.second, notification) } else { // Stop the service if no active downloads remain - stopMonitoringDownloads() + stopForegroundServiceForDownloads() } } @@ -511,7 +510,7 @@ class DownloadMonitorService : Service() { downloadNotificationModel, object : AssignNewForegroundServiceNotification { override fun assignNewForegroundServiceNotification(downloadId: Long) { - cancelNotification(downloadId) + cancelNotificationAndAssignNewNotificationToForegroundService(downloadId) } } ) @@ -605,7 +604,7 @@ class DownloadMonitorService : Service() { super.onDestroy() } - private fun stopMonitoringDownloads() { + private fun stopForegroundServiceForDownloads() { foreGroundServiceInformation = true to DEFAULT_INT_VALUE monitoringDisposable?.dispose() stopForeground(STOP_FOREGROUND_REMOVE) From 05891eba03581c7070c6dea7535b55a6db51c4af Mon Sep 17 00:00:00 2001 From: MohitMaliFtechiz Date: Tue, 26 Nov 2024 21:14:19 +0530 Subject: [PATCH 4/4] Fixed: CustomDownloadFragment was not showing if there is no ZIM file available in custom apps. --- .../kiwix/kiwixmobile/custom/main/CustomFileValidator.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/custom/src/main/java/org/kiwix/kiwixmobile/custom/main/CustomFileValidator.kt b/custom/src/main/java/org/kiwix/kiwixmobile/custom/main/CustomFileValidator.kt index 1785e9830..a72543ae3 100644 --- a/custom/src/main/java/org/kiwix/kiwixmobile/custom/main/CustomFileValidator.kt +++ b/custom/src/main/java/org/kiwix/kiwixmobile/custom/main/CustomFileValidator.kt @@ -123,7 +123,13 @@ class CustomFileValidator @Inject constructor(private val context: Context) { directoryList.add(dir) } } - return scanDirs(directoryList.toTypedArray(), "zim") + return scanDirs(directoryList.toTypedArray(), "zim").filterNot { + // Excluding the demo.zim file from the list as it is used for demonstration purposes + // on the ZimHostFragment for hosting the ZIM file on the server. + // Since we are now using the "asset delivery mode", in this we are using the + // assetFileDescriptor instead of a regular file. + it.name.equals("demo.zim", ignoreCase = true) + } } private fun scanDirs(dirs: Array?, extensionToMatch: String): List =