diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 07fe617eaa..34244d904e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,9 @@ android:name="android.permission.NEARBY_WIFI_DEVICES" android:usesPermissionFlags="neverForLocation" tools:targetApi="s" /> + + + + if (isNotConnected) { + noInternetSnackbar() + return@let + } downloader.pauseResumeDownload( it.downloadId, it.downloadState.toReadableState(context).contains(getString(string.paused_state)) diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml index 1a5bdc0a3c..f6729f3899 100644 --- a/core/src/main/AndroidManifest.xml +++ b/core/src/main/AndroidManifest.xml @@ -13,12 +13,8 @@ - - - - - + + @@ -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/dao/DownloadRoomDao.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/dao/DownloadRoomDao.kt index 6f21298c13..0d4aafcbda 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/dao/DownloadRoomDao.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/dao/DownloadRoomDao.kt @@ -113,7 +113,7 @@ abstract class DownloadRoomDao { sharedPreferenceUtil: SharedPreferenceUtil ) { if (doesNotAlreadyExist(book)) { - val downloadRequest = DownloadRequest(url) + val downloadRequest = DownloadRequest(url, book.title) saveDownload( DownloadRoomEntity( url, diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/dao/entities/DownloadRoomEntity.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/dao/entities/DownloadRoomEntity.kt index 1a049bae46..dff17d8740 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/dao/entities/DownloadRoomEntity.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/dao/entities/DownloadRoomEntity.kt @@ -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, @@ -99,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/main/java/org/kiwix/kiwixmobile/core/data/KiwixRoomDatabase.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/data/KiwixRoomDatabase.kt index 58a89a6c70..a5561b5786 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/data/KiwixRoomDatabase.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/data/KiwixRoomDatabase.kt @@ -44,7 +44,7 @@ import org.kiwix.kiwixmobile.core.dao.entities.ZimSourceRoomConverter NotesRoomEntity::class, DownloadRoomEntity::class ], - version = 5, + version = 6, exportSchema = false ) @TypeConverters(HistoryRoomDaoCoverts::class, ZimSourceRoomConverter::class) @@ -62,7 +62,13 @@ abstract class KiwixRoomDatabase : RoomDatabase() { ?: Room.databaseBuilder(context, KiwixRoomDatabase::class.java, "KiwixRoom.db") // We have already database name called kiwix.db in order to avoid complexity we named // as kiwixRoom.db - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) + .addMigrations( + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6 + ) .build().also { db = it } } } @@ -202,6 +208,15 @@ abstract class KiwixRoomDatabase : RoomDatabase() { } } + @Suppress("MagicNumber") + private val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "ALTER TABLE DownloadRoomEntity ADD COLUMN pausedByUser INTEGER NOT NULL DEFAULT 0" + ) + } + } + fun destroyInstance() { db = null } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/di/components/CoreServiceComponent.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/di/components/CoreServiceComponent.kt index 27e559a844..bec1c4c337 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/di/components/CoreServiceComponent.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/di/components/CoreServiceComponent.kt @@ -23,14 +23,12 @@ import dagger.BindsInstance import dagger.Subcomponent import org.kiwix.kiwixmobile.core.di.CoreServiceScope import org.kiwix.kiwixmobile.core.di.modules.CoreServiceModule -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadMonitorService import org.kiwix.kiwixmobile.core.read_aloud.ReadAloudService @Subcomponent(modules = [CoreServiceModule::class]) @CoreServiceScope interface CoreServiceComponent { fun inject(readAloudService: ReadAloudService) - fun inject(downloadMonitorService: DownloadMonitorService) @Subcomponent.Builder interface Builder { diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/di/modules/DownloaderModule.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/di/modules/DownloaderModule.kt index f85bf4e09e..a599aa4363 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/di/modules/DownloaderModule.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/di/modules/DownloaderModule.kt @@ -18,8 +18,6 @@ package org.kiwix.kiwixmobile.core.di.modules import android.app.DownloadManager -import android.app.NotificationManager -import android.content.Context import dagger.Module import dagger.Provides import org.kiwix.kiwixmobile.core.dao.DownloadRoomDao @@ -30,7 +28,6 @@ import org.kiwix.kiwixmobile.core.downloader.DownloaderImpl import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadManagerBroadcastReceiver import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadManagerMonitor import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadManagerRequester -import org.kiwix.kiwixmobile.core.downloader.downloadManager.DownloadNotificationManager import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil import javax.inject.Singleton @@ -69,11 +66,4 @@ object DownloaderModule { fun providesDownloadManagerBroadcastReceiver( callback: DownloadManagerBroadcastReceiver.Callback ): DownloadManagerBroadcastReceiver = DownloadManagerBroadcastReceiver(callback) - - @Provides - @Singleton - fun providesDownloadNotificationManager( - context: Context, - notificationManager: NotificationManager - ): DownloadNotificationManager = DownloadNotificationManager(context, notificationManager) } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerMonitor.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerMonitor.kt index 4d41202155..4496f37e86 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerMonitor.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerMonitor.kt @@ -18,37 +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()) @@ -57,19 +110,9 @@ 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 (shouldStartService()) { - startService() - } else { - // Do nothing; it is for fixing the error when "if" is used as an expression. - } + val downloadingList = downloadRoomDao.downloads().blockingFirst() + if (downloadingList.isNotEmpty()) { + checkDownloads(downloadingList) } else { monitoringDisposable?.dispose() } @@ -85,61 +128,459 @@ 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 = - getActiveDownloads().isNotEmpty() && - !context.isServiceRunning(DownloadMonitorService::class.java) + @SuppressLint("Range") + private fun checkDownloads(downloadingList: List) { + synchronized(lock) { + downloadingList.forEach { + queryDownloadStatus(it.downloadId) + } + } + } - private fun getActiveDownloads(): List = - downloadRoomDao.downloadRoomEntity().blockingFirst().filter { - it.status != Status.PAUSED && it.status != Status.CANCELLED + @SuppressLint("Range") + fun queryDownloadStatus(downloadId: Long) { + synchronized(lock) { + updater.onNext { + downloadManager.query(DownloadManager.Query().setFilterById(downloadId)).use { cursor -> + if (cursor.moveToFirst()) { + handleDownloadStatus(cursor, downloadId) + } else { + handleCancelledDownload(downloadId) + } + } + } } + } - override fun downloadCompleteOrCancelled(intent: Intent) { + @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) { - intent.extras?.let { - val downloadId = it.getLong(DownloadManager.EXTRA_DOWNLOAD_ID, -1L) - if (downloadId != -1L) { - context.startService( - getDownloadMonitorIntent( - ACTION_QUERY_DOWNLOAD_STATUS, - downloadId.toInt() - ) - ) + updater.onNext { + 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 { + 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 startService() { - 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, shouldUpdateStatus = false) + 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, shouldUpdateStatus = false) + 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() + fun resumeDownload( + downloadId: Long, + shouldUpdateStatus: Boolean = true + ) { + synchronized(lock) { + updater.onNext { + if (pauseResumeDownloadInDownloadManagerContentResolver( + downloadId, + CONTROL_RUN, + STATUS_RUNNING + ) + ) { + if (shouldUpdateStatus) { + // pass false when user resumed the download to proceed with further checks. + updateDownloadStatus(downloadId, Status.QUEUED, Error.NONE, pausedByUser = false) + } + } + } + } } fun cancelDownload(downloadId: Long) { - context.startService(getDownloadMonitorIntent(ACTION_CANCEL, downloadId.toInt())) - startMonitoringDownloads() + synchronized(lock) { + updater.onNext { + // Remove the download from DownloadManager on IO thread. + downloadManager.remove(downloadId) + handleCancelledDownload(downloadId) + } + } } - private fun getDownloadMonitorIntent(action: String, downloadId: Int): Intent = - Intent(context, DownloadMonitorService::class.java).apply { - putExtra(DownloadNotificationManager.NOTIFICATION_ACTION, action) - putExtra(DownloadNotificationManager.EXTRA_DOWNLOAD_ID, downloadId) + @SuppressLint("Range") + private fun pauseResumeDownloadInDownloadManagerContentResolver( + downloadId: Long, + control: Int, + status: Int + ): Boolean { + return try { + // Update the status to paused/resumed in the database + val contentValues = ContentValues().apply { + put(COLUMN_CONTROL, control) + put(DownloadManager.COLUMN_STATUS, status) + } + val uri = ContentUris.withAppendedId(downloadBaseUri, downloadId) + context.contentResolver + .update(uri, contentValues, null, null) + true + } catch (ignore: Exception) { + Log.e("DOWNLOAD_MONITOR", "Couldn't pause/resume the download. Original exception = $ignore") + false } + } + + private fun shouldUpdateDownloadStatus(downloadRoomEntity: DownloadRoomEntity) = + downloadRoomEntity.status != Status.COMPLETED override fun init() { // empty method to so class does not get reported unused } } + +data class DownloadInfo( + var startTime: Long, + var initialBytesDownloaded: Long +) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerRequester.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerRequester.kt index 25f9e6685e..616560f16a 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerRequester.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadManagerRequester.kt @@ -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 @@ -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) @@ -105,7 +106,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(":", "") @@ -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) @@ -123,7 +125,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 403db07dfb..0000000000 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadMonitorService.kt +++ /dev/null @@ -1,629 +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.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() - ) { - 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.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 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 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 { - it.status != Status.PAUSED && it.status != Status.CANCELLED - } - - 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 - ) - ) { - updateDownloadStatus(downloadId, Status.PAUSED, Error.NONE) - } - } - } - } - - private fun resumeDownload(downloadId: Long) { - synchronized(lock) { - updater.onNext { - if (pauseResumeDownloadInDownloadManagerContentResolver( - downloadId, - CONTROL_RUN, - STATUS_RUNNING - ) - ) { - updateDownloadStatus(downloadId, Status.QUEUED, Error.NONE) - } - } - } - } - - private fun cancelDownload(downloadId: Long) { - synchronized(lock) { - updater.onNext { - // Remove the download from DownloadManager on IO thread. - downloadManager.remove(downloadId) - handleCancelledDownload(downloadId) - } - } - } - - @SuppressLint("Range") - private fun pauseResumeDownloadInDownloadManagerContentResolver( - downloadId: Long, - control: Int, - status: Int - ): Boolean { - return try { - // Update the status to paused/resumed in the database - val contentValues = ContentValues().apply { - put(COLUMN_CONTROL, control) - put(DownloadManager.COLUMN_STATUS, status) - } - val uri = ContentUris.withAppendedId(downloadBaseUri, downloadId) - contentResolver - .update(uri, contentValues, null, null) - true - } catch (ignore: Exception) { - Log.e("DOWNLOAD_MONITOR", "Couldn't pause/resume the download. Original exception = $ignore") - false - } - } - - private fun shouldUpdateDownloadStatus(downloadRoomEntity: DownloadRoomEntity) = - downloadRoomEntity.status != Status.COMPLETED - - override fun onDestroy() { - monitoringDisposable?.dispose() - super.onDestroy() - } - - private fun stopForegroundServiceForDownloads() { - foreGroundServiceInformation = true to DEFAULT_INT_VALUE - monitoringDisposable?.dispose() - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } -} - -data class DownloadInfo( - var startTime: Long, - var initialBytesDownloaded: Long -) - -interface AssignNewForegroundServiceNotification { - fun assignNewForegroundServiceNotification(downloadId: Long) -} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationManager.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationManager.kt deleted file mode 100644 index e7c8600efd..0000000000 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationManager.kt +++ /dev/null @@ -1,259 +0,0 @@ -/* - * Kiwix Android - * Copyright (c) 2024 Kiwix - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package org.kiwix.kiwixmobile.core.downloader.downloadManager - -import android.annotation.SuppressLint -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Build -import androidx.annotation.RequiresApi -import androidx.core.app.NotificationCompat -import org.kiwix.kiwixmobile.core.Intents -import org.kiwix.kiwixmobile.core.R -import org.kiwix.kiwixmobile.core.downloader.model.Seconds -import org.kiwix.kiwixmobile.core.main.CoreMainActivity -import org.kiwix.kiwixmobile.core.utils.DEFAULT_NOTIFICATION_TIMEOUT_AFTER -import org.kiwix.kiwixmobile.core.utils.DEFAULT_NOTIFICATION_TIMEOUT_AFTER_RESET -import org.kiwix.kiwixmobile.core.utils.DOWNLOAD_NOTIFICATION_CHANNEL_ID -import java.util.Locale -import javax.inject.Inject - -const val DOWNLOAD_NOTIFICATION_TITLE = "OPEN_ZIM_FILE" - -class DownloadNotificationManager @Inject constructor( - private val context: Context, - private val notificationManager: NotificationManager -) { - private val downloadNotificationsBuilderMap = mutableMapOf() - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (notificationManager.getNotificationChannel(DOWNLOAD_NOTIFICATION_CHANNEL_ID) == null) { - notificationManager.createNotificationChannel(createChannel(context)) - } - } - } - - fun updateNotification( - downloadNotificationModel: DownloadNotificationModel, - assignNewForegroundServiceNotification: AssignNewForegroundServiceNotification - ) { - synchronized(downloadNotificationsBuilderMap) { - if (shouldUpdateNotification(downloadNotificationModel)) { - notificationManager.notify( - downloadNotificationModel.downloadId, - createNotification(downloadNotificationModel) - ) - } else { - // the download is cancelled/paused so remove the notification. - assignNewForegroundServiceNotification.assignNewForegroundServiceNotification( - downloadNotificationModel.downloadId.toLong() - ) - } - } - } - - fun createNotification(downloadNotificationModel: DownloadNotificationModel): Notification { - synchronized(downloadNotificationsBuilderMap) { - createNotificationChannel() - val notificationBuilder = getNotificationBuilder(downloadNotificationModel.downloadId) - val smallIcon = if (downloadNotificationModel.progress != HUNDERED) { - android.R.drawable.stat_sys_download - } else { - android.R.drawable.stat_sys_download_done - } - - notificationBuilder.setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setSmallIcon(smallIcon) - .setContentTitle(downloadNotificationModel.title) - .setContentText(getSubtitleText(context, downloadNotificationModel)) - .setOngoing(downloadNotificationModel.isOnGoingNotification) - .setGroupSummary(false) - if (downloadNotificationModel.isFailed || downloadNotificationModel.isCompleted) { - notificationBuilder.setProgress(ZERO, ZERO, false) - } else { - notificationBuilder.setProgress(HUNDERED, downloadNotificationModel.progress, false) - } - when { - downloadNotificationModel.isDownloading -> - notificationBuilder.setTimeoutAfter(DEFAULT_NOTIFICATION_TIMEOUT_AFTER) - .addAction( - R.drawable.ic_baseline_stop, - context.getString(R.string.cancel), - getActionPendingIntent(ACTION_CANCEL, downloadNotificationModel.downloadId) - ).addAction( - R.drawable.ic_baseline_pause, - getPauseOrResumeTitle(true), - getActionPendingIntent(ACTION_PAUSE, downloadNotificationModel.downloadId) - ) - - downloadNotificationModel.isPaused -> - notificationBuilder.setTimeoutAfter(DEFAULT_NOTIFICATION_TIMEOUT_AFTER) - .addAction( - R.drawable.ic_baseline_stop, - context.getString(R.string.cancel), - getActionPendingIntent(ACTION_CANCEL, downloadNotificationModel.downloadId) - ).addAction( - R.drawable.ic_baseline_play, - getPauseOrResumeTitle(false), - getActionPendingIntent(ACTION_RESUME, downloadNotificationModel.downloadId) - ) - - downloadNotificationModel.isQueued -> - notificationBuilder.setTimeoutAfter(DEFAULT_NOTIFICATION_TIMEOUT_AFTER) - - else -> notificationBuilder.setTimeoutAfter(DEFAULT_NOTIFICATION_TIMEOUT_AFTER_RESET) - } - notificationCustomisation(downloadNotificationModel, notificationBuilder, context) - return@createNotification notificationBuilder.build() - } - } - - private fun getPauseOrResumeTitle(isPause: Boolean): String { - val pauseOrResumeTitle = if (isPause) { - context.getString(R.string.tts_pause) - } else { - context.getString(R.string.tts_resume) - } - return pauseOrResumeTitle.replaceFirstChar { - if (it.isLowerCase()) { - it.titlecase(Locale.ROOT) - } else { - "$it" - } - } - } - - private fun shouldUpdateNotification( - downloadNotificationModel: DownloadNotificationModel - ): Boolean = !downloadNotificationModel.isCancelled && !downloadNotificationModel.isPaused - - @SuppressLint("UnspecifiedImmutableFlag") - private fun notificationCustomisation( - downloadNotificationModel: DownloadNotificationModel, - notificationBuilder: NotificationCompat.Builder, - context: Context - ) { - if (downloadNotificationModel.isCompleted) { - val internal = Intents.internal(CoreMainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - putExtra(DOWNLOAD_NOTIFICATION_TITLE, downloadNotificationModel.filePath) - } - val pendingIntent = - PendingIntent.getActivity( - context, - ZERO, - internal, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - notificationBuilder.setContentIntent(pendingIntent) - notificationBuilder.setAutoCancel(true) - } - } - - @SuppressLint("RestrictedApi") - private fun getNotificationBuilder(notificationId: Int): NotificationCompat.Builder { - synchronized(downloadNotificationsBuilderMap) { - val notificationBuilder = downloadNotificationsBuilderMap[notificationId] - ?: NotificationCompat.Builder(context, DOWNLOAD_NOTIFICATION_CHANNEL_ID) - downloadNotificationsBuilderMap[notificationId] = notificationBuilder - notificationBuilder - .setGroup("$notificationId") - .setStyle(null) - .setProgress(ZERO, ZERO, false) - .setContentTitle(null) - .setContentText(null) - .setContentIntent(null) - .setGroupSummary(false) - .setTimeoutAfter(DEFAULT_NOTIFICATION_TIMEOUT_AFTER_RESET) - .setOngoing(false) - .setOnlyAlertOnce(true) - .setSmallIcon(android.R.drawable.stat_sys_download_done) - .mActions.clear() - return@getNotificationBuilder notificationBuilder - } - } - - private fun getActionPendingIntent(action: String, downloadId: Int): PendingIntent { - val pendingIntent = - Intent(context, DownloadMonitorService::class.java).apply { - putExtra(NOTIFICATION_ACTION, action) - putExtra(EXTRA_DOWNLOAD_ID, downloadId) - } - val requestCode = downloadId + action.hashCode() - return PendingIntent.getService( - context, - requestCode, - pendingIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createChannel(context: Context) = - NotificationChannel( - DOWNLOAD_NOTIFICATION_CHANNEL_ID, - context.getString(R.string.download_notification_channel_name), - NotificationManager.IMPORTANCE_HIGH - ).apply { - setSound(null, null) - enableVibration(false) - } - - private fun getSubtitleText( - context: Context, - downloadNotificationModel: DownloadNotificationModel - ): String { - return when { - downloadNotificationModel.isCompleted -> context.getString(R.string.complete) - downloadNotificationModel.isFailed -> context.getString( - R.string.failed_state, - downloadNotificationModel.error - ) - - downloadNotificationModel.isPaused -> context.getString(R.string.paused_state) - downloadNotificationModel.isQueued -> context.getString(R.string.pending_state) - downloadNotificationModel.etaInMilliSeconds <= ZERO -> - context.getString(R.string.running_state) - - else -> Seconds( - downloadNotificationModel.etaInMilliSeconds / THOUSAND.toLong() - ).toHumanReadableTime() - } - } - - fun cancelNotification(notificationId: Int) { - synchronized(downloadNotificationsBuilderMap) { - notificationManager.cancel(notificationId) - downloadNotificationsBuilderMap.remove(notificationId) - } - } - - companion object { - const val NOTIFICATION_ACTION = "notification_action" - const val ACTION_PAUSE = "action_pause" - const val ACTION_RESUME = "action_resume" - const val ACTION_CANCEL = "action_cancel" - const val ACTION_QUERY_DOWNLOAD_STATUS = "action_query_download_status" - const val EXTRA_DOWNLOAD_ID = "extra_download_id" - } -} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationModel.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationModel.kt deleted file mode 100644 index 17f2de2c9e..0000000000 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/downloadManager/DownloadNotificationModel.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Kiwix Android - * Copyright (c) 2024 Kiwix - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package org.kiwix.kiwixmobile.core.downloader.downloadManager - -data class DownloadNotificationModel( - val downloadId: Int, - val status: Status = Status.NONE, - val progress: Int, - val etaInMilliSeconds: Long, - val title: String, - val description: String?, - val filePath: String?, - val error: String -) { - val isPaused get() = status == Status.PAUSED - val isCompleted get() = status == Status.COMPLETED - val isFailed get() = status == Status.FAILED - val isQueued get() = status == Status.QUEUED - val isDownloading get() = status == Status.DOWNLOADING - val isCancelled get() = status == Status.CANCELLED - val isOnGoingNotification: Boolean - get() { - return when (status) { - Status.QUEUED, - Status.DOWNLOADING -> true - - else -> false - } - } -} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadModel.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadModel.kt index e76caf8847..d9db90c596 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadModel.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadModel.kt @@ -33,7 +33,8 @@ data class DownloadModel( var state: Status, var error: Error, var progress: Int, - val book: Book + val book: Book, + var pausedByUser: Boolean ) { val bytesRemaining: Long by lazy { totalSizeOfDownload - bytesDownloaded } val fileNameFromUrl: String by lazy { StorageUtils.getFileNameFromUrl(book.url) } @@ -48,6 +49,7 @@ data class DownloadModel( downloadEntity.status, downloadEntity.error, downloadEntity.progress, - downloadEntity.toBook() + downloadEntity.toBook(), + downloadEntity.pausedByUser ) } diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadRequest.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadRequest.kt index 24ec973856..5d28096058 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadRequest.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/downloader/model/DownloadRequest.kt @@ -22,7 +22,7 @@ import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil import org.kiwix.kiwixmobile.core.utils.StorageUtils import java.io.File -data class DownloadRequest(val urlString: String) { +data class DownloadRequest(val urlString: String, val bookTitle: String) { val uri: Uri get() = Uri.parse(urlString) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt index d3bc7d308c..a028247d2b 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/search/SearchFragment.kt @@ -192,11 +192,12 @@ class SearchFragment : BaseFragment() { ) } + @Suppress("UnnecessarySafeCall") private fun setupToolbar(view: View) { view.post { - with(requireActivity() as CoreMainActivity) { - setSupportActionBar(view.findViewById(R.id.toolbar)) - supportActionBar?.apply { + with(activity as? CoreMainActivity) { + this?.setSupportActionBar(view.findViewById(R.id.toolbar)) + this?.supportActionBar?.apply { setHomeButtonEnabled(true) title = getString(R.string.menu_search_in_text) } diff --git a/core/src/sharedTestFunctions/java/org/kiwix/sharedFunctions/TestModelFunctions.kt b/core/src/sharedTestFunctions/java/org/kiwix/sharedFunctions/TestModelFunctions.kt index a8fae21a4b..e6955b2757 100644 --- a/core/src/sharedTestFunctions/java/org/kiwix/sharedFunctions/TestModelFunctions.kt +++ b/core/src/sharedTestFunctions/java/org/kiwix/sharedFunctions/TestModelFunctions.kt @@ -58,7 +58,7 @@ fun downloadModel( book: Book = book() ) = DownloadModel( databaseId, downloadId, file, etaInMilliSeconds, bytesDownloaded, totalSizeOfDownload, - status, error, progress, book + status, error, progress, book, false ) fun downloadItem(