Merge pull request #4108 from kiwix/Fixes#4106

Fixed: Download notification was disappearing when the application is in background.
This commit is contained in:
Kelson 2024-11-26 18:07:07 +01:00 committed by GitHub
commit 308fe39d8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 742 additions and 616 deletions

View File

@ -8,9 +8,6 @@
tools:ignore="CoarseFineLocation" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="${permission}" />
<!-- Device with versions >= Pie need this permission -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation"

View File

@ -24,7 +24,6 @@ import android.content.Context
import dagger.Module
import dagger.Provides
import org.kiwix.kiwixmobile.core.qr.GenerateQR
import org.kiwix.kiwixmobile.core.read_aloud.ReadAloudNotificationManger
import org.kiwix.kiwixmobile.di.ServiceScope
import org.kiwix.kiwixmobile.webserver.KiwixServer
import org.kiwix.kiwixmobile.webserver.WebServerHelper
@ -34,12 +33,6 @@ import org.kiwix.kiwixmobile.webserver.wifi_hotspot.IpAddressCallbacks
@Module
class ServiceModule {
@Provides
@ServiceScope
fun providesReadAloudNotificationManager(
notificationManager: NotificationManager,
context: Context
): ReadAloudNotificationManger = ReadAloudNotificationManger(notificationManager, context)
@Provides
@ServiceScope

View File

@ -16,6 +16,9 @@
<uses-permission
android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT"/>
<!-- Device with versions >= Pie need this permission -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
@ -90,5 +93,8 @@
android:name=".error.DiagnosticReportActivity"
android:exported="false" />
<service android:name=".read_aloud.ReadAloudService" />
<service
android:name=".downloader.downloadManager.DownloadMonitorService"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View File

@ -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)

View File

@ -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 {

View File

@ -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(

View File

@ -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/"

View File

@ -18,527 +18,83 @@
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.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 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 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
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
private val context: Context
) : DownloadMonitor, DownloadManagerBroadcastReceiver.Callback {
private val updater = PublishSubject.create<() -> Unit>()
private val lock = Any()
private val downloadInfoMap = mutableMapOf<Long, DownloadInfo>()
private var monitoringDisposable: Disposable? = null
init {
startMonitoringDownloads()
setupUpdater()
CoroutineScope(Dispatchers.IO).launch {
if (getActiveDownloads().isNotEmpty()) {
startService()
}
}
}
private suspend fun getActiveDownloads(): List<DownloadRoomEntity> =
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) {
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"
context.startService(
getDownloadMonitorIntent(
ACTION_QUERY_DOWNLOAD_STATUS,
downloadId.toInt()
)
}
},
Throwable::printStackTrace
)
}
@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)
}
}
}
fun startMonitoringDownloads() {
startService()
}
@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() {
context.startService(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)
}
}
}
context.startService(getDownloadMonitorIntent(ACTION_PAUSE, downloadId.toInt()))
}
fun resumeDownload(downloadId: Long) {
synchronized(lock) {
updater.onNext {
if (pauseResumeDownloadInDownloadManagerContentResolver(
downloadId,
CONTROL_RUN,
STATUS_RUNNING
)
) {
updateDownloadStatus(downloadId, Status.QUEUED, Error.NONE)
}
}
}
context.startService(getDownloadMonitorIntent(ACTION_RESUME, downloadId.toInt()))
}
fun cancelDownload(downloadId: Long) {
synchronized(lock) {
downloadManager.remove(downloadId)
handleCancelledDownload(downloadId)
}
context.startService(getDownloadMonitorIntent(ACTION_CANCEL, downloadId.toInt()))
}
@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 getDownloadMonitorIntent(action: String, downloadId: Int): Intent =
Intent(context, DownloadMonitorService::class.java).apply {
putExtra(DownloadNotificationManager.NOTIFICATION_ACTION, action)
putExtra(DownloadNotificationManager.EXTRA_DOWNLOAD_ID, downloadId)
}
}
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
)

View File

@ -0,0 +1,622 @@
/*
* Kiwix Android
* Copyright (c) 2024 Kiwix <android.kiwix.org>
* 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 <http://www.gnu.org/licenses/>.
*
*/
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<Long, DownloadInfo>()
private val updater = PublishSubject.create<() -> Unit>()
private var foreGroundServiceInformation: Pair<Boolean, Int> = 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 -> 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.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
}
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<DownloadRoomEntity> =
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) {
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: Int
)
interface AssignNewForegroundServiceNotification {
fun assignNewForegroundServiceNotification(downloadId: Long)
}

View File

@ -1,49 +0,0 @@
/*
* Kiwix Android
* Copyright (c) 2024 Kiwix <android.kiwix.org>
* 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 <http://www.gnu.org/licenses/>.
*
*/
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 downloadManagerMonitor: DownloadManagerMonitor
) : 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 -> downloadManagerMonitor.pauseDownload(downloadId.toLong())
ACTION_RESUME -> downloadManagerMonitor.resumeDownload(downloadId.toLong())
ACTION_CANCEL -> downloadManagerMonitor.cancelDownload(downloadId.toLong())
}
}
}
}

View File

@ -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
@ -53,71 +54,80 @@ class DownloadNotificationManager @Inject constructor(
}
fun updateNotification(
downloadNotificationModel: DownloadNotificationModel
downloadNotificationModel: DownloadNotificationModel,
assignNewForegroundServiceNotification: AssignNewForegroundServiceNotification
) {
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.
cancelNotification(downloadNotificationModel.downloadId)
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)
@ -184,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
)
}
@ -203,7 +213,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)
@ -243,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"
}
}

View File

@ -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()
}

View File

@ -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<out File?>?, extensionToMatch: String): List<File> =