mirror of
https://github.com/kiwix/kiwix-android.git
synced 2025-08-03 10:46:53 -04:00
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:
commit
4b8fe6df69
@ -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"
|
||||
|
@ -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))
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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(
|
||||
|
Loading…
x
Reference in New Issue
Block a user