From aa25b3a14c89c134637c3d008119e10b5c6715d3 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:38:07 +0600 Subject: [PATCH] Download app update without WorkManager (#3707) --- .../data/notification/Notifications.kt | 9 +- .../data/updater/AppUpdateDownloadJob.kt | 131 ------------------ .../tachiyomi/ui/more/NewUpdateScreenModel.kt | 113 ++++++++------- .../moko-resources/base/strings.xml | 2 - 4 files changed, 57 insertions(+), 198 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateDownloadJob.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt index a0664f37f..96a0d52b9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt @@ -65,11 +65,9 @@ object Notifications { const val ID_INCOGNITO_MODE = -701 /** - * Notification channel and ids used for app and extension updates. + * Notification channel and ids used for extension updates. */ private const val GROUP_APK_UPDATES = "group_apk_updates" - const val CHANNEL_APP_UPDATE = "app_apk_update_channel" - const val ID_APP_UPDATER = 1 const val CHANNEL_EXTENSIONS_UPDATE = "ext_apk_update_channel" const val ID_UPDATES_TO_EXTS = -401 const val ID_EXTENSION_INSTALLER = -402 @@ -84,6 +82,7 @@ object Notifications { "downloader_cache_renewal", "crash_logs_channel", "library_skipped_channel", + "app_apk_update_channel", ) /** @@ -157,10 +156,6 @@ object Notifications { buildNotificationChannel(CHANNEL_INCOGNITO_MODE, IMPORTANCE_LOW) { setName(context.stringResource(MR.strings.pref_incognito_mode)) }, - buildNotificationChannel(CHANNEL_APP_UPDATE, IMPORTANCE_DEFAULT) { - setGroup(GROUP_APK_UPDATES) - setName(context.stringResource(MR.strings.channel_app_updates)) - }, buildNotificationChannel(CHANNEL_EXTENSIONS_UPDATE, IMPORTANCE_DEFAULT) { setGroup(GROUP_APK_UPDATES) setName(context.stringResource(MR.strings.channel_ext_updates)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateDownloadJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateDownloadJob.kt deleted file mode 100644 index ee19e8942..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateDownloadJob.kt +++ /dev/null @@ -1,131 +0,0 @@ -package eu.kanade.tachiyomi.data.updater - -import android.content.Context -import android.content.pm.ServiceInfo -import android.os.Build -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.ExistingWorkPolicy -import androidx.work.ForegroundInfo -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkerParameters -import androidx.work.workDataOf -import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.NetworkHelper -import eu.kanade.tachiyomi.network.ProgressListener -import eu.kanade.tachiyomi.network.awaitSuccess -import eu.kanade.tachiyomi.network.newCachelessCallWithProgress -import eu.kanade.tachiyomi.util.storage.saveTo -import eu.kanade.tachiyomi.util.system.notificationBuilder -import eu.kanade.tachiyomi.util.system.setForegroundSafely -import eu.kanade.tachiyomi.util.system.workManager -import kotlinx.coroutines.CancellationException -import tachiyomi.core.common.i18n.stringResource -import tachiyomi.core.common.util.lang.withIOContext -import tachiyomi.i18n.MR -import uy.kohesive.injekt.injectLazy -import java.io.File - -class AppUpdateDownloadJob(private val context: Context, workerParams: WorkerParameters) : - CoroutineWorker(context, workerParams) { - - private val network: NetworkHelper by injectLazy() - - override suspend fun doWork(): Result { - val url = inputData.getString(EXTRA_DOWNLOAD_URL) - - if (url.isNullOrEmpty()) { - return Result.failure() - } - - setForegroundSafely() - - return try { - withIOContext { downloadApk(url) } - Result.success(workDataOf(PROGRESS to 100, EXTRA_DOWNLOAD_URL to url)) - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - Result.failure() - } - } - - override suspend fun getForegroundInfo(): ForegroundInfo { - val notification = context.notificationBuilder(Notifications.CHANNEL_APP_UPDATE) - .setContentTitle(context.stringResource(MR.strings.update_check_notification_update_available)) - .setContentText(context.stringResource(MR.strings.update_check_notification_download_in_progress)) - .setSmallIcon(android.R.drawable.stat_sys_download) - .setOngoing(true) - .build() - - return ForegroundInfo( - Notifications.ID_APP_UPDATER, - notification, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - } else { - 0 - }, - ) - } - - /** - * Called to start downloading apk of new update - * - * @param url url location of file - */ - private suspend fun downloadApk(url: String) { - val progressListener = object : ProgressListener { - // Progress of the download - var savedProgress = 0 - - // Keep track of the last notification sent to avoid posting too many. - var lastTick = 0L - - override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { - val progress = (100 * (bytesRead.toFloat() / contentLength)).toInt() - val currentTime = System.currentTimeMillis() - if (progress > savedProgress && currentTime - 200 > lastTick) { - savedProgress = progress - lastTick = currentTime - setProgressAsync(workDataOf(PROGRESS to progress, EXTRA_DOWNLOAD_URL to url)) - } - } - } - - val response = network.client.newCachelessCallWithProgress(GET(url), progressListener).awaitSuccess() - - val apkFile = updateApk(context) - response.body.source().saveTo(apkFile) - } - - companion object { - const val TAG = "AppUpdateDownload" - - const val PROGRESS = "progress" - - const val EXTRA_DOWNLOAD_URL = "DOWNLOAD_URL" - - fun updateApk(context: Context): File = File(context.externalCacheDir, "update.apk") - - fun start(context: Context, url: String) { - val constraints = Constraints( - requiredNetworkType = NetworkType.CONNECTED, - ) - - val request = OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .addTag(TAG) - .setInputData(workDataOf(EXTRA_DOWNLOAD_URL to url)) - .build() - - context.workManager.enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request) - } - - fun stop(context: Context) { - context.workManager.cancelUniqueWork(TAG) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/NewUpdateScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/NewUpdateScreenModel.kt index beb77f252..580e480ea 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/NewUpdateScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/NewUpdateScreenModel.kt @@ -7,79 +7,80 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import androidx.work.WorkInfo -import eu.kanade.tachiyomi.data.updater.AppUpdateDownloadJob import eu.kanade.tachiyomi.extension.util.ExtensionInstaller +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.ProgressListener +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.newCachelessCallWithProgress import eu.kanade.tachiyomi.util.storage.getUriCompat -import eu.kanade.tachiyomi.util.system.workManager -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onEach +import eu.kanade.tachiyomi.util.storage.saveTo +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import logcat.LogPriority import mihon.core.viewmodel.StateViewModel +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.core.common.util.system.logcat import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import java.io.File class NewUpdateScreenModel( changelogInfo: String, private val downloadLink: String, private val context: Context = Injekt.get(), + private val network: NetworkHelper = Injekt.get(), ) : StateViewModel(State(changelogInfo = changelogInfo)) { - init { - context.workManager.getWorkInfosByTagFlow(AppUpdateDownloadJob.TAG) - .mapNotNull { it.firstOrNull() } - .map { workInfo -> - val progress = if (workInfo.state.isFinished) { - workInfo.outputData.getInt(AppUpdateDownloadJob.PROGRESS, 0) - } else { - workInfo.progress.getInt(AppUpdateDownloadJob.PROGRESS, 0) - } - val url = if (workInfo.state.isFinished) { - workInfo.outputData.getString(AppUpdateDownloadJob.EXTRA_DOWNLOAD_URL) - } else { - workInfo.progress.getString(AppUpdateDownloadJob.EXTRA_DOWNLOAD_URL) - } + private val apkFile: File + get() = File(context.externalCacheDir, "update.apk") - if (url != downloadLink) { - return@map 0 to Stage.Available - } - - val stage = when { - workInfo.state == WorkInfo.State.FAILED -> Stage.Failed - workInfo.state.isFinished && progress == 100 -> { - if (AppUpdateDownloadJob.updateApk(context).exists()) { - Stage.Downloaded - } else { - Stage.Available - } - } - workInfo.state in listOf(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING) -> Stage.Downloading - else -> Stage.Available - } - progress to stage - } - .distinctUntilChanged() - .onEach { (progress, stage) -> - mutableState.update { - it.copy( - downloadProgress = progress, - stage = stage, - ) - } - } - .launchIn(viewModelScope) - } + private var downloadJob: Job? = null fun startDownload() { - mutableState.update { it.copy(downloadProgress = 0, stage = Stage.Downloading) } - AppUpdateDownloadJob.start(context, downloadLink) + if (downloadJob?.isActive == true) return + + downloadJob = viewModelScope.launch { + mutableState.update { it.copy(downloadProgress = 0, stage = Stage.Downloading) } + try { + withIOContext { downloadApk() } + mutableState.update { it.copy(downloadProgress = 100, stage = Stage.Downloaded) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + apkFile.delete() + mutableState.update { it.copy(stage = Stage.Failed) } + } + } + } + + private suspend fun downloadApk() { + val progressListener = object : ProgressListener { + // Progress of the download + var savedProgress = 0 + + // Keep track of the last update sent to avoid updating the state too often. + var lastTick = 0L + + override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { + val progress = (100 * (bytesRead.toFloat() / contentLength)).toInt() + val currentTime = System.currentTimeMillis() + if (progress > savedProgress && currentTime - 200 > lastTick) { + savedProgress = progress + lastTick = currentTime + mutableState.update { it.copy(downloadProgress = progress) } + } + } + } + + val response = network.client.newCachelessCallWithProgress(GET(downloadLink), progressListener).awaitSuccess() + response.body.source().saveTo(apkFile) } fun installUpdate() { - val apkFile = AppUpdateDownloadJob.updateApk(context) val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(apkFile.getUriCompat(context), ExtensionInstaller.APK_MIME) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION @@ -87,10 +88,6 @@ class NewUpdateScreenModel( context.startActivity(intent) } - override fun onCleared() { - AppUpdateDownloadJob.stop(context) - } - @Immutable data class State( val changelogInfo: String, diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index ccba4a401..614735c59 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -951,7 +951,6 @@ No new updates available - Downloading… New version available! @@ -990,7 +989,6 @@ Complete Errors Chapter updates - App updates Extension updates