From eed57b80be077a9f73c36d8dc572f34f59c1ba8d Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 27 Oct 2023 15:37:59 -0400 Subject: [PATCH] Replace AppUpdateService with a WorkManager job Fixes #7773 Co-authored-by: Jays2Kings --- app/src/main/AndroidManifest.xml | 4 - .../data/notification/NotificationReceiver.kt | 29 ++- .../data/updater/AppUpdateDownloadJob.kt | 148 +++++++++++++ .../data/updater/AppUpdateNotifier.kt | 14 +- .../data/updater/AppUpdateService.kt | 195 ------------------ .../tachiyomi/ui/more/NewUpdateScreen.kt | 4 +- 6 files changed, 183 insertions(+), 211 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateDownloadJob.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c3c2f288c..442a123f3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -145,10 +145,6 @@ android:name=".data.download.DownloadService" android:exported="false" /> - - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index 5110aaca6..566da8e9c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.backup.BackupRestoreJob import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.library.LibraryUpdateJob -import eu.kanade.tachiyomi.data.updater.AppUpdateService +import eu.kanade.tachiyomi.data.updater.AppUpdateDownloadJob import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.util.storage.DiskUtil @@ -85,6 +85,8 @@ class NotificationReceiver : BroadcastReceiver() { ACTION_CANCEL_RESTORE -> cancelRestore(context) // Cancel library update and dismiss notification ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) + // Start downloading app update + ACTION_START_APP_UPDATE -> startDownloadAppUpdate(context, intent) // Cancel downloading app update ACTION_CANCEL_APP_UPDATE_DOWNLOAD -> cancelDownloadAppUpdate(context) // Open reader activity @@ -209,8 +211,13 @@ class NotificationReceiver : BroadcastReceiver() { LibraryUpdateJob.stop(context) } + private fun startDownloadAppUpdate(context: Context, intent: Intent) { + val url = intent.getStringExtra(AppUpdateDownloadJob.EXTRA_DOWNLOAD_URL) ?: return + AppUpdateDownloadJob.start(context, url) + } + private fun cancelDownloadAppUpdate(context: Context) { - AppUpdateService.stop(context) + AppUpdateDownloadJob.stop(context) } /** @@ -268,6 +275,7 @@ class NotificationReceiver : BroadcastReceiver() { private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE" + private const val ACTION_START_APP_UPDATE = "$ID.$NAME.ACTION_START_APP_UPDATE" private const val ACTION_CANCEL_APP_UPDATE_DOWNLOAD = "$ID.$NAME.CANCEL_APP_UPDATE_DOWNLOAD" private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ" @@ -499,10 +507,25 @@ class NotificationReceiver : BroadcastReceiver() { return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } + /** + * Returns [PendingIntent] that starts the [AppUpdateDownloadJob] to download an app update. + * + * @param context context of application + * @return [PendingIntent] + */ + internal fun downloadAppUpdatePendingBroadcast(context: Context, url: String, title: String? = null): PendingIntent { + return Intent(context, NotificationReceiver::class.java).run { + action = ACTION_START_APP_UPDATE + putExtra(AppUpdateDownloadJob.EXTRA_DOWNLOAD_URL, url) + title?.let { putExtra(AppUpdateDownloadJob.EXTRA_DOWNLOAD_TITLE, it) } + PendingIntent.getBroadcast(context, 0, this, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + } + } + /** * */ - internal fun cancelUpdateDownloadPendingBroadcast(context: Context): PendingIntent { + internal fun cancelDownloadAppUpdatePendingBroadcast(context: Context): PendingIntent { val intent = Intent(context, NotificationReceiver::class.java).apply { action = ACTION_CANCEL_APP_UPDATE_DOWNLOAD } 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 new file mode 100644 index 000000000..60d5e71ec --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateDownloadJob.kt @@ -0,0 +1,148 @@ +package eu.kanade.tachiyomi.data.updater + +import android.content.Context +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.R +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.await +import eu.kanade.tachiyomi.network.newCachelessCallWithProgress +import eu.kanade.tachiyomi.util.storage.getUriCompat +import eu.kanade.tachiyomi.util.storage.saveTo +import eu.kanade.tachiyomi.util.system.workManager +import logcat.LogPriority +import okhttp3.internal.http2.ErrorCode +import okhttp3.internal.http2.StreamResetException +import tachiyomi.core.util.lang.withIOContext +import tachiyomi.core.util.system.logcat +import uy.kohesive.injekt.injectLazy +import java.io.File +import kotlin.coroutines.cancellation.CancellationException + +class AppUpdateDownloadJob(private val context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + + private val notifier = AppUpdateNotifier(context) + private val network: NetworkHelper by injectLazy() + + override suspend fun doWork(): Result { + val url = inputData.getString(EXTRA_DOWNLOAD_URL) + val title = inputData.getString(EXTRA_DOWNLOAD_TITLE) ?: context.getString(R.string.app_name) + + if (url.isNullOrEmpty()) { + return Result.failure() + } + + try { + setForeground(getForegroundInfo()) + } catch (e: IllegalStateException) { + logcat(LogPriority.ERROR, e) { "Not allowed to run on foreground service" } + } + + withIOContext { + downloadApk(title, url) + } + + return Result.success() + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + return ForegroundInfo( + Notifications.ID_APP_UPDATER, + notifier.onDownloadStarted().build(), + ) + } + + /** + * Called to start downloading apk of new update + * + * @param url url location of file + */ + private suspend fun downloadApk(title: String, url: String) { + // Show notification download starting. + notifier.onDownloadStarted(title) + + 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 + notifier.onProgressChange(progress) + } + } + } + + try { + // Download the new update. + val response = network.client.newCachelessCallWithProgress(GET(url), progressListener) + .await() + + // File where the apk will be saved. + val apkFile = File(context.externalCacheDir, "update.apk") + + if (response.isSuccessful) { + response.body.source().saveTo(apkFile) + } else { + response.close() + throw Exception("Unsuccessful response") + } + notifier.cancel() + notifier.promptInstall(apkFile.getUriCompat(context)) + } catch (e: Exception) { + val shouldCancel = e is CancellationException || + (e is StreamResetException && e.errorCode == ErrorCode.CANCEL) + if (shouldCancel) { + notifier.cancel() + } else { + notifier.onDownloadError(url) + } + } + } + + companion object { + private const val TAG = "AppUpdateDownload" + + const val EXTRA_DOWNLOAD_URL = "DOWNLOAD_URL" + const val EXTRA_DOWNLOAD_TITLE = "DOWNLOAD_TITLE" + + fun start(context: Context, url: String, title: String? = null) { + val constraints = Constraints( + requiredNetworkType = NetworkType.CONNECTED, + ) + + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .addTag(TAG) + .setInputData( + workDataOf( + EXTRA_DOWNLOAD_URL to url, + EXTRA_DOWNLOAD_TITLE to title, + ), + ) + .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/data/updater/AppUpdateNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateNotifier.kt index 728967654..0437103a3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateNotifier.kt @@ -34,11 +34,11 @@ internal class AppUpdateNotifier(private val context: Context) { @SuppressLint("LaunchActivityFromNotification") fun promptUpdate(release: Release) { - val updateIntent = Intent(context, AppUpdateService::class.java).run { - putExtra(AppUpdateService.EXTRA_DOWNLOAD_URL, release.getDownloadLink()) - putExtra(AppUpdateService.EXTRA_DOWNLOAD_TITLE, release.version) - PendingIntent.getService(context, 0, this, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - } + val updateIntent = NotificationReceiver.downloadAppUpdatePendingBroadcast( + context, + release.getDownloadLink(), + release.version, + ) val releaseIntent = Intent(Intent.ACTION_VIEW, release.releaseLink.toUri()).run { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP @@ -82,7 +82,7 @@ internal class AppUpdateNotifier(private val context: Context) { addAction( R.drawable.ic_close_24dp, context.getString(R.string.action_cancel), - NotificationReceiver.cancelUpdateDownloadPendingBroadcast(context), + NotificationReceiver.cancelDownloadAppUpdatePendingBroadcast(context), ) } notificationBuilder.show() @@ -164,7 +164,7 @@ internal class AppUpdateNotifier(private val context: Context) { addAction( R.drawable.ic_refresh_24dp, context.getString(R.string.action_retry), - AppUpdateService.downloadApkPendingService(context, url), + NotificationReceiver.downloadAppUpdatePendingBroadcast(context, url), ) addAction( R.drawable.ic_close_24dp, diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateService.kt deleted file mode 100644 index c1f973d2d..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateService.kt +++ /dev/null @@ -1,195 +0,0 @@ -package eu.kanade.tachiyomi.data.updater - -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.os.IBinder -import android.os.PowerManager -import androidx.core.content.ContextCompat -import eu.kanade.tachiyomi.BuildConfig -import eu.kanade.tachiyomi.R -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.await -import eu.kanade.tachiyomi.network.newCachelessCallWithProgress -import eu.kanade.tachiyomi.util.storage.getUriCompat -import eu.kanade.tachiyomi.util.storage.saveTo -import eu.kanade.tachiyomi.util.system.acquireWakeLock -import eu.kanade.tachiyomi.util.system.isServiceRunning -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import okhttp3.internal.http2.ErrorCode -import okhttp3.internal.http2.StreamResetException -import uy.kohesive.injekt.injectLazy -import java.io.File - -class AppUpdateService : Service() { - - private val network: NetworkHelper by injectLazy() - - /** - * Wake lock that will be held until the service is destroyed. - */ - private lateinit var wakeLock: PowerManager.WakeLock - private lateinit var notifier: AppUpdateNotifier - - private val job = SupervisorJob() - private val serviceScope = CoroutineScope(Dispatchers.IO + job) - - override fun onCreate() { - notifier = AppUpdateNotifier(this) - wakeLock = acquireWakeLock(javaClass.name) - - startForeground(Notifications.ID_APP_UPDATER, notifier.onDownloadStarted().build()) - } - - /** - * This method needs to be implemented, but it's not used/needed. - */ - override fun onBind(intent: Intent): IBinder? = null - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent == null) return START_NOT_STICKY - - val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return START_NOT_STICKY - val title = intent.getStringExtra(EXTRA_DOWNLOAD_TITLE) ?: getString(R.string.app_name) - - serviceScope.launch { - downloadApk(title, url) - } - - job.invokeOnCompletion { stopSelf(startId) } - return START_NOT_STICKY - } - - override fun stopService(name: Intent?): Boolean { - destroyJob() - return super.stopService(name) - } - - override fun onDestroy() { - destroyJob() - } - - private fun destroyJob() { - serviceScope.cancel() - job.cancel() - if (wakeLock.isHeld) { - wakeLock.release() - } - } - - /** - * Called to start downloading apk of new update - * - * @param url url location of file - */ - private suspend fun downloadApk(title: String, url: String) { - // Show notification download starting. - notifier.onDownloadStarted(title) - - 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 - notifier.onProgressChange(progress) - } - } - } - - try { - // Download the new update. - val response = network.client.newCachelessCallWithProgress(GET(url), progressListener) - .await() - - // File where the apk will be saved. - val apkFile = File(externalCacheDir, "update.apk") - - if (response.isSuccessful) { - response.body.source().saveTo(apkFile) - } else { - response.close() - throw Exception("Unsuccessful response") - } - notifier.cancel() - notifier.promptInstall(apkFile.getUriCompat(this)) - } catch (e: Exception) { - val shouldCancel = e is CancellationException || - (e is StreamResetException && e.errorCode == ErrorCode.CANCEL) - if (shouldCancel) { - notifier.cancel() - } else { - notifier.onDownloadError(url) - } - } - } - - companion object { - - internal const val EXTRA_DOWNLOAD_URL = "${BuildConfig.APPLICATION_ID}.UpdaterService.DOWNLOAD_URL" - internal const val EXTRA_DOWNLOAD_TITLE = "${BuildConfig.APPLICATION_ID}.UpdaterService.DOWNLOAD_TITLE" - - /** - * Returns the status of the service. - * - * @param context the application context. - * @return true if the service is running, false otherwise. - */ - private fun isRunning(context: Context): Boolean = - context.isServiceRunning(AppUpdateService::class.java) - - /** - * Downloads a new update and let the user install the new version from a notification. - * - * @param context the application context. - * @param url the url to the new update. - */ - fun start(context: Context, url: String, title: String? = context.getString(R.string.app_name)) { - if (isRunning(context)) return - - Intent(context, AppUpdateService::class.java).apply { - putExtra(EXTRA_DOWNLOAD_TITLE, title) - putExtra(EXTRA_DOWNLOAD_URL, url) - ContextCompat.startForegroundService(context, this) - } - } - - /** - * Stops the service. - * - * @param context the application context - */ - fun stop(context: Context) { - context.stopService(Intent(context, AppUpdateService::class.java)) - } - - /** - * Returns [PendingIntent] that starts a service which downloads the apk specified in url. - * - * @param url the url to the new update. - * @return [PendingIntent] - */ - internal fun downloadApkPendingService(context: Context, url: String): PendingIntent { - return Intent(context, AppUpdateService::class.java).run { - putExtra(EXTRA_DOWNLOAD_URL, url) - PendingIntent.getService(context, 0, this, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/NewUpdateScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/NewUpdateScreen.kt index f6ebdab64..eae88d838 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/NewUpdateScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/NewUpdateScreen.kt @@ -7,7 +7,7 @@ import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.presentation.more.NewUpdateScreen import eu.kanade.presentation.util.Screen -import eu.kanade.tachiyomi.data.updater.AppUpdateService +import eu.kanade.tachiyomi.data.updater.AppUpdateDownloadJob import eu.kanade.tachiyomi.util.system.openInBrowser class NewUpdateScreen( @@ -31,7 +31,7 @@ class NewUpdateScreen( onOpenInBrowser = { context.openInBrowser(releaseLink) }, onRejectUpdate = navigator::pop, onAcceptUpdate = { - AppUpdateService.start( + AppUpdateDownloadJob.start( context = context, url = downloadLink, title = versionName,