Merge pull request #4130 from kiwix/Issue#4106

Fixed: Downloads were not automatically starting showing progress when reopening the app(If downloads paused due to any network error).
This commit is contained in:
Kelson 2024-12-18 11:48:04 +01:00 committed by GitHub
commit 4b8fe6df69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 575 additions and 1028 deletions

View File

@ -12,6 +12,9 @@
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<!-- 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" />
<application
android:name=".KiwixApp"

View File

@ -148,6 +148,10 @@ class OnlineLibraryFragment : BaseFragment(), FragmentActivityExtensions {
},
{
context?.let { context ->
if (isNotConnected) {
noInternetSnackbar()
return@let
}
downloader.pauseResumeDownload(
it.downloadId,
it.downloadState.toReadableState(context).contains(getString(string.paused_state))

View File

@ -13,12 +13,8 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<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" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
@ -52,8 +48,8 @@
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules = "@xml/data_extraction_rules"
android:hardwareAccelerated="true"
android:largeHeap="true"
android:requestLegacyExternalStorage="true"
@ -80,9 +76,9 @@
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true"
tools:node="merge"
tools:overrideLibrary="com.squareup.picasso.picasso"
android:grantUriPermissions="true">
tools:overrideLibrary="com.squareup.picasso.picasso">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
@ -92,8 +88,5 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,37 +18,89 @@
package org.kiwix.kiwixmobile.core.downloader.downloadManager
import android.annotation.SuppressLint
import android.app.DownloadManager
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<Long, DownloadInfo>()
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 +109,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 +127,490 @@ 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)
private fun getActiveDownloads(): List<DownloadRoomEntity> =
downloadRoomDao.downloadRoomEntity().blockingFirst().filter {
it.status != Status.PAUSED && it.status != Status.CANCELLED
}
override fun downloadCompleteOrCancelled(intent: Intent) {
@SuppressLint("Range")
private fun checkDownloads(downloadingList: List<DownloadModel>) {
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()
)
)
downloadingList.forEach {
queryDownloadStatus(it.downloadId)
}
}
}
@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)
}
}
}
}
}
private fun startService() {
context.startService(Intent(context, DownloadMonitorService::class.java))
@SuppressLint("Range")
private fun handleDownloadStatus(cursor: Cursor, downloadId: Long) {
val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
val reason = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON))
val bytesDownloaded =
cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
val totalBytes = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
val progress = calculateProgress(bytesDownloaded, totalBytes)
val etaInMilliSeconds = calculateETA(downloadId, bytesDownloaded, totalBytes)
when (status) {
DownloadManager.STATUS_FAILED -> handleFailedDownload(
downloadId,
reason,
progress,
etaInMilliSeconds,
bytesDownloaded,
totalBytes
)
DownloadManager.STATUS_PAUSED -> handlePausedDownload(
downloadId,
progress,
bytesDownloaded,
totalBytes,
reason
)
DownloadManager.STATUS_PENDING -> handlePendingDownload(downloadId)
DownloadManager.STATUS_RUNNING -> handleRunningDownload(
downloadId,
progress,
etaInMilliSeconds,
bytesDownloaded,
totalBytes
)
DownloadManager.STATUS_SUCCESSFUL -> handleSuccessfulDownload(
downloadId,
progress,
etaInMilliSeconds
)
}
}
private fun handleCancelledDownload(downloadId: Long) {
updater.onNext {
updateDownloadStatus(downloadId, Status.CANCELLED, Error.CANCELLED)
downloadRoomDao.delete(downloadId)
downloadInfoMap.remove(downloadId)
}
}
@Suppress("LongParameterList")
private fun handleFailedDownload(
downloadId: Long,
reason: Int,
progress: Int,
etaInMilliSeconds: Long,
bytesDownloaded: Long,
totalBytes: Long
) {
val error = mapDownloadError(reason)
updateDownloadStatus(
downloadId,
Status.FAILED,
error,
progress,
etaInMilliSeconds,
bytesDownloaded,
totalBytes
)
}
private fun handlePausedDownload(
downloadId: Long,
progress: Int,
bytesDownloaded: Long,
totalSizeOfDownload: Long,
reason: Int
) {
val pauseReason = mapDownloadPauseReason(reason)
updateDownloadStatus(
downloadId = downloadId,
status = Status.PAUSED,
error = pauseReason,
progress = progress,
bytesDownloaded = bytesDownloaded,
totalSizeOfDownload = totalSizeOfDownload
)
}
private fun handlePendingDownload(downloadId: Long) {
updateDownloadStatus(
downloadId,
Status.QUEUED,
Error.NONE
)
}
private fun handleRunningDownload(
downloadId: Long,
progress: Int,
etaInMilliSeconds: Long,
bytesDownloaded: Long,
totalSizeOfDownload: Long
) {
updateDownloadStatus(
downloadId,
Status.DOWNLOADING,
Error.NONE,
progress,
etaInMilliSeconds,
bytesDownloaded,
totalSizeOfDownload
)
}
private fun handleSuccessfulDownload(
downloadId: Long,
progress: Int,
etaInMilliSeconds: Long
) {
updateDownloadStatus(
downloadId,
Status.COMPLETED,
Error.NONE,
progress,
etaInMilliSeconds
)
downloadInfoMap.remove(downloadId)
}
private fun calculateProgress(bytesDownloaded: Long, totalBytes: Long): Int =
if (totalBytes > ZERO) {
(bytesDownloaded / totalBytes.toDouble()).times(HUNDERED).toInt()
} else {
ZERO
}
private fun calculateETA(downloadedFileId: Long, bytesDownloaded: Long, totalBytes: Long): Long {
val currentTime = System.currentTimeMillis()
val downloadInfo = downloadInfoMap.getOrPut(downloadedFileId) {
DownloadInfo(startTime = currentTime, initialBytesDownloaded = bytesDownloaded)
}
val elapsedTime = currentTime - downloadInfo.startTime
val downloadSpeed = if (elapsedTime > ZERO) {
(bytesDownloaded - downloadInfo.initialBytesDownloaded) / (elapsedTime / THOUSAND.toFloat())
} else {
ZERO.toFloat()
}
return if (downloadSpeed > ZERO) {
((totalBytes - bytesDownloaded) / downloadSpeed).toLong() * THOUSAND
} else {
ZERO.toLong()
}
}
private fun mapDownloadError(reason: Int): Error {
return when (reason) {
DownloadManager.ERROR_CANNOT_RESUME -> Error.ERROR_CANNOT_RESUME
DownloadManager.ERROR_DEVICE_NOT_FOUND -> Error.ERROR_DEVICE_NOT_FOUND
DownloadManager.ERROR_FILE_ALREADY_EXISTS -> Error.ERROR_FILE_ALREADY_EXISTS
DownloadManager.ERROR_FILE_ERROR -> Error.ERROR_FILE_ERROR
DownloadManager.ERROR_HTTP_DATA_ERROR -> Error.ERROR_HTTP_DATA_ERROR
DownloadManager.ERROR_INSUFFICIENT_SPACE -> Error.ERROR_INSUFFICIENT_SPACE
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> Error.ERROR_TOO_MANY_REDIRECTS
DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> Error.ERROR_UNHANDLED_HTTP_CODE
DownloadManager.ERROR_UNKNOWN -> Error.UNKNOWN
else -> Error.UNKNOWN
}
}
private fun mapDownloadPauseReason(reason: Int): Error {
return when (reason) {
DownloadManager.PAUSED_QUEUED_FOR_WIFI -> Error.QUEUED_FOR_WIFI
DownloadManager.PAUSED_WAITING_TO_RETRY -> Error.WAITING_TO_RETRY
DownloadManager.PAUSED_WAITING_FOR_NETWORK -> Error.WAITING_FOR_NETWORK
DownloadManager.PAUSED_UNKNOWN -> Error.PAUSED_UNKNOWN
else -> Error.PAUSED_UNKNOWN
}
}
@Suppress("LongParameterList")
private fun updateDownloadStatus(
downloadId: Long,
status: Status,
error: Error,
progress: Int = DEFAULT_INT_VALUE,
etaInMilliSeconds: Long = DEFAULT_INT_VALUE.toLong(),
bytesDownloaded: Long = DEFAULT_INT_VALUE.toLong(),
totalSizeOfDownload: Long = DEFAULT_INT_VALUE.toLong(),
pausedByUser: Boolean? = null
) {
synchronized(lock) {
updater.onNext {
Log.e(
"DOWNLOAD_MONITOR",
"updateDownloadStatus: " +
"\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
}
}
}
}
}
/**
* 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)
}
context.contentResolver
.update(
downloadBaseUri,
contentValues,
getWhereClauseForIds(longArrayOf(downloadId)),
getWhereArgsForIds(longArrayOf(downloadId))
)
true
} catch (ignore: Exception) {
Log.e("DOWNLOAD_MONITOR", "Couldn't pause/resume the download. Original exception = $ignore")
false
}
}
private fun getWhereArgsForIds(ids: LongArray): Array<String?> {
val whereArgs = arrayOfNulls<String>(ids.size)
return getWhereArgsForIds(ids, whereArgs)
}
private fun getWhereArgsForIds(ids: LongArray, args: Array<String?>): Array<String?> {
assert(args.size >= ids.size)
for (i in ids.indices) {
args[i] = "${ids[i]}"
}
return args
}
private fun getWhereClauseForIds(ids: LongArray): String {
val whereClause = StringBuilder()
whereClause.append("(")
for (i in ids.indices) {
if (i > 0) {
whereClause.append("OR ")
}
whereClause.append("_id")
whereClause.append(" = ? ")
}
whereClause.append(")")
return "$whereClause"
}
private fun shouldUpdateDownloadStatus(downloadRoomEntity: DownloadRoomEntity) =
downloadRoomEntity.status != Status.COMPLETED
override fun init() {
// empty method to so class does not get reported unused
}
}
data class DownloadInfo(
var startTime: Long,
var initialBytesDownloaded: Long
)

View File

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

View File

@ -1,629 +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.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 -> {
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<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) {
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)
}

View File

@ -1,259 +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.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<Int, NotificationCompat.Builder>()
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"
}
}

View File

@ -1,46 +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
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
}
}
}

View File

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

View File

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

View File

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

View File

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