From 314036145208fcacf908f7ff74015e81321f8f20 Mon Sep 17 00:00:00 2001 From: Jays2Kings Date: Mon, 31 Jul 2023 16:19:43 -0400 Subject: [PATCH] Targeting SDK 33 (Android 13) (#1525) * starting workmanager updates * Update Download service to download job Also making downloader use suspend methods like upstream Co-Authored-By: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com> * Make BackupRestorer a job * Make LibraryUpdateJob a datasync service type * Changing Extension auto installer to a job instead of service * Changing App auto installer to a job instead of service With it theres no more services, and nearly ready to up the target sdk * Add runtime permission for notifications Shows permission when adding to library, seeing the app update in app prompt, or loading library or recents Tries to show the permission again (or a warning message) when trying to restore a backup or set library update timing Same warning messages shows when not allowing notifications * Set target sdk to 33 we made it. * Clean up ContextExtensions * Add notification check for incognito mode * Add last updated timestamp to updates job * Update LibraryUpdateJob.kt minor changes to the notifier's placeholder, and making sure it uses localeContext for versions under A13 * update channel logic in library updater * Change library update channel to just take a Long instead a whole Manga * Use extensionManager flow in ExtensionInstallerJob * Update MainActivity.kt * Fixes to downloadFlow * reworking running extensions after library update logic * Change update channel to shared flow in library job * More updates to the library updates flow no longer using a suspend, instead holding a buffer for the flow * updates to the flow in extensionInstaller from state to shared, also using "tryEmit" less for it * Fix extension auto installing notification not dismissing/dismissable * Version 1.7.0 * Update AppDownloadInstallJob.kt * Refactor DownloadJob * Version 1.7.0-b02 * Fix uninstalling extensions not refreshing the list * Show notification permission prompt when pressing update all * Chunked the extension install job in case too many extensions are being updated at once The limit is around 62 extensions, but to be safe it runs 32 per job (which jobs still update 3 at a time) Closes #1584 * Update Java Version --------- Co-authored-by: Ivan Iskandar <12537387+ivaniskandar@users.noreply.github.com> --- app/build.gradle.kts | 18 +- app/src/main/AndroidManifest.xml | 21 +- app/src/main/java/eu/kanade/tachiyomi/App.kt | 13 +- .../java/eu/kanade/tachiyomi/Migrations.kt | 8 +- .../data/backup/AbstractBackupRestore.kt | 3 - .../tachiyomi/data/backup/BackupConst.kt | 2 +- .../tachiyomi/data/backup/BackupCreatorJob.kt | 4 +- .../tachiyomi/data/backup/BackupNotifier.kt | 10 +- .../tachiyomi/data/backup/BackupRestoreJob.kt | 81 ++ .../data/backup/BackupRestoreService.kt | 143 ---- .../tachiyomi/data/backup/BackupRestorer.kt | 29 +- .../tachiyomi/data/download/DownloadJob.kt | 133 ++++ .../data/download/DownloadManager.kt | 47 +- .../data/download/DownloadNotifier.kt | 26 +- .../data/download/DownloadService.kt | 267 ------- .../tachiyomi/data/download/Downloader.kt | 420 ++++++----- .../tachiyomi/data/download/model/Download.kt | 8 +- .../data/library/LibraryUpdateJob.kt | 701 ++++++++++++++++- .../data/library/LibraryUpdateNotifier.kt | 11 +- .../data/library/LibraryUpdateService.kt | 712 ------------------ .../data/notification/NotificationReceiver.kt | 85 ++- .../data/preference/PreferencesHelper.kt | 8 +- .../data/updater/AppDownloadInstallJob.kt | 293 +++++++ .../data/updater/AppUpdateBroadcast.kt | 14 +- .../data/updater/AppUpdateChecker.kt | 4 +- .../tachiyomi/data/updater/AppUpdateJob.kt | 2 +- .../data/updater/AppUpdateNotifier.kt | 43 +- .../data/updater/AppUpdateService.kt | 297 -------- .../data/updater/AutoAppUpdaterJob.kt | 71 -- .../extension/ExtensionInstallService.kt | 201 ----- .../extension/ExtensionInstallerJob.kt | 205 +++++ .../tachiyomi/extension/ExtensionManager.kt | 68 +- .../tachiyomi/extension/ExtensionUpdateJob.kt | 58 +- .../extension/util/ExtensionInstaller.kt | 36 +- .../ui/download/DownloadBottomPresenter.kt | 3 +- .../ui/download/DownloadBottomSheet.kt | 9 +- .../ui/extension/ExtensionBottomPresenter.kt | 36 +- .../ui/extension/ExtensionBottomSheet.kt | 9 +- .../tachiyomi/ui/library/LibraryController.kt | 36 +- .../ui/library/LibraryHeaderHolder.kt | 4 +- .../kanade/tachiyomi/ui/main/MainActivity.kt | 58 +- .../ui/manga/MangaDetailsController.kt | 4 +- .../ui/manga/MangaDetailsPresenter.kt | 21 +- .../tachiyomi/ui/more/AboutController.kt | 4 +- .../tachiyomi/ui/recents/RecentsController.kt | 27 +- .../tachiyomi/ui/recents/RecentsPresenter.kt | 30 +- .../ui/setting/SettingsAdvancedController.kt | 8 +- .../ui/setting/SettingsBackupController.kt | 7 +- .../ui/setting/SettingsBrowseController.kt | 4 +- .../ui/setting/SettingsGeneralController.kt | 4 +- .../ui/setting/SettingsLibraryController.kt | 2 + .../kanade/tachiyomi/util/MangaExtensions.kt | 4 + .../kanade/tachiyomi/util/storage/DiskUtil.kt | 2 + .../util/system/ContextExtensions.kt | 112 +-- .../util/system/PackageManagerExtensions.kt | 39 + app/src/main/res/values/strings.xml | 1 + build.gradle.kts | 2 +- buildSrc/src/main/kotlin/Dependencies.kt | 6 +- gradle.properties | 5 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 60 files changed, 2133 insertions(+), 2348 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreJob.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadJob.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/updater/AppDownloadInstallJob.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateService.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/updater/AutoAppUpdaterJob.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionInstallService.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionInstallerJob.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/util/system/PackageManagerExtensions.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 21ddc6b785..f3cf7565c3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -116,11 +116,11 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "17" } namespace = "eu.kanade.tachiyomi" } @@ -159,7 +159,6 @@ dependencies { implementation("androidx.palette:palette:1.0.0") implementation("androidx.activity:activity-ktx:1.7.0") implementation("androidx.core:core-ktx:1.10.0") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1") implementation("com.google.android.flexbox:flexbox:3.0.0") implementation("androidx.window:window:1.0.0") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") @@ -173,8 +172,11 @@ dependencies { implementation("com.google.firebase:firebase-analytics-ktx") implementation("com.google.firebase:firebase-crashlytics-ktx") - val lifecycleVersion = "2.5.1" + val lifecycleVersion = "2.6.1" kapt("androidx.lifecycle:lifecycle-compiler:$lifecycleVersion") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion") + implementation("androidx.lifecycle:lifecycle-common:$lifecycleVersion") implementation("androidx.lifecycle:lifecycle-process:$lifecycleVersion") implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion") @@ -213,13 +215,13 @@ dependencies { // Disk implementation("com.jakewharton:disklrucache:2.0.2") implementation("com.github.tachiyomiorg:unifile:17bec43") - implementation("com.github.junrar:junrar:7.5.0") + implementation("com.github.junrar:junrar:7.5.4") // HTML parser - implementation("org.jsoup:jsoup:1.15.3") + implementation("org.jsoup:jsoup:1.15.4") // Job scheduling - implementation("androidx.work:work-runtime-ktx:2.6.0") + implementation("androidx.work:work-runtime-ktx:2.8.0") implementation("com.google.guava:guava:31.1-android") implementation("com.google.android.gms:play-services-gcm:17.0.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 232663c397..ce21e5a3cb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -241,24 +241,9 @@ - - - - - - - - + android:name="androidx.work.impl.foreground.SystemForegroundService" + android:foregroundServiceType="dataSync" + tools:node="merge" /> (protected val co protected val trackManager: TrackManager by injectLazy() protected val customMangaManager: CustomMangaManager by injectLazy() - var job: Job? = null - protected lateinit var backupManager: T protected var restoreAmount = 0 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt index af226f931a..a4a5585296 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt @@ -4,7 +4,7 @@ import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID object BackupConst { - private const val NAME = "BackupRestoreServices" + private const val NAME = "BackupRestorer" const val EXTRA_URI = "$ID.$NAME.EXTRA_URI" // Filter options diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt index d7dba96e83..1b880dccd4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt @@ -33,7 +33,7 @@ class BackupCreatorJob(private val context: Context, workerParams: WorkerParamet val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupConst.BACKUP_ALL) val isAutoBackup = inputData.getBoolean(IS_AUTO_BACKUP_KEY, true) - context.notificationManager.notify(Notifications.ID_BACKUP_PROGRESS, notifier.showBackupProgress().build()) + notifier.showBackupProgress() return try { val location = BackupManager(context).createBackup(uri, flags, isAutoBackup) if (!isAutoBackup) notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())) @@ -68,7 +68,7 @@ class BackupCreatorJob(private val context: Context, workerParams: WorkerParamet .setInputData(workDataOf(IS_AUTO_BACKUP_KEY to true)) .build() - workManager.enqueueUniquePeriodicWork(TAG_AUTO, ExistingPeriodicWorkPolicy.REPLACE, request) + workManager.enqueueUniquePeriodicWork(TAG_AUTO, ExistingPeriodicWorkPolicy.UPDATE, request) } else { workManager.cancelUniqueWork(TAG_AUTO) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt index a3b0028109..0dfad79ac3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt @@ -36,7 +36,7 @@ class BackupNotifier(private val context: Context) { context.notificationManager.notify(id, build()) } - fun showBackupProgress(): NotificationCompat.Builder { + fun showBackupProgress() { val builder = with(progressNotificationBuilder) { setContentTitle(context.getString(R.string.creating_backup)) @@ -45,8 +45,6 @@ class BackupNotifier(private val context: Context) { } builder.show(Notifications.ID_BACKUP_PROGRESS) - - return builder } fun showBackupError(error: String?) { @@ -88,7 +86,7 @@ class BackupNotifier(private val context: Context) { setContentText(content) } - setProgress(maxAmount, progress, false) + setProgress(maxAmount, progress, progress == -1) setOnlyAlertOnce(true) // Clear old actions if they exist @@ -101,7 +99,9 @@ class BackupNotifier(private val context: Context) { ) } - builder.show(Notifications.ID_RESTORE_PROGRESS) + if (progress != -1) { + builder.show(Notifications.ID_RESTORE_PROGRESS) + } return builder } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreJob.kt new file mode 100644 index 0000000000..a62d824060 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreJob.kt @@ -0,0 +1,81 @@ +package eu.kanade.tachiyomi.data.backup + +import android.content.Context +import android.content.pm.ServiceInfo +import android.net.Uri +import android.os.Build +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.util.system.jobIsRunning +import eu.kanade.tachiyomi.util.system.localeContext +import eu.kanade.tachiyomi.util.system.tryToSetForeground +import eu.kanade.tachiyomi.util.system.withIOContext +import kotlinx.coroutines.CancellationException + +class BackupRestoreJob(val context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { + + private val notifier = BackupNotifier(context.localeContext) + private val restorer = BackupRestorer(context, notifier) + + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = notifier.showRestoreProgress(progress = -1).build() + val id = Notifications.ID_RESTORE_PROGRESS + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(id, notification) + } + } + + override suspend fun doWork(): Result { + tryToSetForeground() + + val uriPath = inputData.getString(BackupConst.EXTRA_URI) ?: return Result.failure() + + val uri = Uri.parse(uriPath) ?: return Result.failure() + + withIOContext { + try { + if (!restorer.restoreBackup(uri)) { + notifier.showRestoreError(context.getString(R.string.restoring_backup_canceled)) + } + } catch (exception: Exception) { + if (exception is CancellationException) { + notifier.showRestoreError(context.getString(R.string.restoring_backup_canceled)) + } else { + restorer.writeErrorLog() + notifier.showRestoreError(exception.message) + } + } + } + return Result.success() + } + + companion object { + private const val TAG = "BackupRestorer" + + fun start(context: Context, uri: Uri) { + val request = OneTimeWorkRequestBuilder() + .addTag(TAG) + .setInputData(workDataOf(BackupConst.EXTRA_URI to uri.toString())) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request) + } + + fun stop(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(TAG) + } + + fun isRunning(context: Context) = WorkManager.getInstance(context).jobIsRunning(TAG) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt deleted file mode 100644 index f2dcd86b0b..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt +++ /dev/null @@ -1,143 +0,0 @@ -package eu.kanade.tachiyomi.data.backup - -import android.app.Service -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.IBinder -import android.os.PowerManager -import androidx.core.content.ContextCompat -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.util.system.acquireWakeLock -import eu.kanade.tachiyomi.util.system.isServiceRunning -import eu.kanade.tachiyomi.util.system.localeContext -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import timber.log.Timber - -/** - * Restores backup. - */ -class BackupRestoreService : Service() { - - companion object { - - /** - * Returns the status of the service. - * - * @param context the application context. - * @return true if the service is running, false otherwise. - */ - fun isRunning(context: Context): Boolean = - context.isServiceRunning(BackupRestoreService::class.java) - - /** - * Starts a service to restore a backup from Json - * - * @param context context of application - * @param uri path of Uri - */ - fun start(context: Context, uri: Uri) { - if (!isRunning(context)) { - val intent = Intent(context, BackupRestoreService::class.java).apply { - putExtra(BackupConst.EXTRA_URI, uri) - } - ContextCompat.startForegroundService(context, intent) - } - } - - /** - * Stops the service. - * - * @param context the application context. - */ - fun stop(context: Context) { - context.stopService(Intent(context, BackupRestoreService::class.java)) - - BackupNotifier(context.localeContext).showRestoreError(context.getString(R.string.restoring_backup_canceled)) - } - } - - /** - * Wake lock that will be held until the service is destroyed. - */ - private lateinit var wakeLock: PowerManager.WakeLock - - private lateinit var ioScope: CoroutineScope - private var restorer: AbstractBackupRestore<*>? = null - private lateinit var notifier: BackupNotifier - - override fun onCreate() { - super.onCreate() - - ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - notifier = BackupNotifier(this.localeContext) - wakeLock = acquireWakeLock() - - startForeground(Notifications.ID_RESTORE_PROGRESS, notifier.showRestoreProgress().build()) - } - - override fun stopService(name: Intent?): Boolean { - destroyJob() - return super.stopService(name) - } - - override fun onDestroy() { - destroyJob() - super.onDestroy() - } - - private fun destroyJob() { - restorer?.job?.cancel() - ioScope.cancel() - if (wakeLock.isHeld) { - wakeLock.release() - } - } - - /** - * This method needs to be implemented, but it's not used/needed. - */ - override fun onBind(intent: Intent): IBinder? = null - - /** - * Method called when the service receives an intent. - * - * @param intent the start intent from. - * @param flags the flags of the command. - * @param startId the start id of this command. - * @return the start value of the command. - */ - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val uri = intent?.getParcelableExtra(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY - - // Cancel any previous job if needed. - restorer?.job?.cancel() - - restorer = BackupRestorer(this, notifier) - - val handler = CoroutineExceptionHandler { _, exception -> - Timber.e(exception) - restorer?.writeErrorLog() - - notifier.showRestoreError(exception.message) - stopSelf(startId) - } - val job = ioScope.launch(handler) { - if (restorer?.restoreBackup(uri) == false) { - notifier.showRestoreError(getString(R.string.restoring_backup_canceled)) - } - } - job.invokeOnCompletion { - stopSelf(startId) - } - restorer?.job = job - - return START_NOT_STICKY - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt index 8802f1dfef..2f51822bbf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestorer.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.data.backup +import android.annotation.SuppressLint import android.content.Context import android.net.Uri import eu.kanade.tachiyomi.R @@ -12,7 +13,9 @@ import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.library.CustomMangaManager -import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive import okio.buffer import okio.gzip import okio.source @@ -20,11 +23,13 @@ import java.util.Date class BackupRestorer(context: Context, notifier: BackupNotifier) : AbstractBackupRestore(context, notifier) { + @SuppressLint("Recycle") @Suppress("BlockingMethodInNonBlockingContext") override suspend fun performRestore(uri: Uri): Boolean { backupManager = BackupManager(context) - val backupString = context.contentResolver.openInputStream(uri)!!.source().gzip().buffer().use { it.readByteArray() } + val stream = context.contentResolver.openInputStream(uri) + val backupString = stream!!.source().gzip().buffer().use { it.readByteArray() } val backup = backupManager.parser.decodeFromByteArray(BackupSerializer, backupString) restoreAmount = backup.backupManga.size + 1 // +1 for categories @@ -38,18 +43,18 @@ class BackupRestorer(context: Context, notifier: BackupNotifier) : AbstractBacku val backupMaps = backup.backupBrokenSources.map { BackupSource(it.name, it.sourceId) } + backup.backupSources sourceMapping = backupMaps.associate { it.sourceId to it.name } - // Restore individual manga - backup.backupManga.forEach { - if (job?.isActive != true) { - return false + return coroutineScope { + // Restore individual manga + backup.backupManga.forEach { + if (!isActive) { + return@coroutineScope false + } + + restoreManga(it, backup.backupCategories) } - - restoreManga(it, backup.backupCategories) + true } - // TODO: optionally trigger online library + tracker update - - return true } private fun restoreCategories(backupCategories: List) { @@ -89,7 +94,7 @@ class BackupRestorer(context: Context, notifier: BackupNotifier) : AbstractBacku restoreProgress += 1 showRestoreProgress(restoreProgress, restoreAmount, manga.title) - LibraryUpdateService.callListener(manga) + LibraryUpdateJob.updateMutableFlow.tryEmit(manga.id) } /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadJob.kt new file mode 100644 index 0000000000..cb38eb7bcb --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadJob.kt @@ -0,0 +1,133 @@ +package eu.kanade.tachiyomi.data.download + +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.extension.ExtensionUpdateJob +import eu.kanade.tachiyomi.util.system.isConnectedToWifi +import eu.kanade.tachiyomi.util.system.isOnline +import eu.kanade.tachiyomi.util.system.tryToSetForeground +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * This worker is used to manage the downloader. The system can decide to stop the worker, in + * which case the downloader is also stopped. It's also stopped while there's no network available. + */ +class DownloadJob(val context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { + + private val downloadManager: DownloadManager = Injekt.get() + private val preferences: PreferencesHelper = Injekt.get() + + override suspend fun getForegroundInfo(): ForegroundInfo { + val firstDL = downloadManager.queue.firstOrNull() + val notification = DownloadNotifier(context).setPlaceholder(firstDL).build() + val id = Notifications.ID_DOWNLOAD_CHAPTER + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(id, notification) + } + } + + override suspend fun doWork(): Result { + tryToSetForeground() + + var networkCheck = checkConnectivity() + var active = networkCheck + if (active) { + downloadManager.startDownloads() + } + val runExtJobAfter = inputData.getBoolean(START_EXT_JOB_AFTER, false) + + // Keep the worker running when needed + return try { + while (active) { + delay(100) + networkCheck = checkConnectivity() + active = !isStopped && networkCheck && downloadManager.isRunning + } + Result.success() + } catch (_: CancellationException) { + Result.success() + } finally { + callListeners(false, downloadManager) + if (runExtJobAfter) { + ExtensionUpdateJob.runJobAgain(applicationContext, NetworkType.CONNECTED) + } + } + } + + private fun checkConnectivity(): Boolean { + return with(applicationContext) { + if (isOnline()) { + val noWifi = preferences.downloadOnlyOverWifi() && !isConnectedToWifi() + if (noWifi) { + downloadManager.stopDownloads(applicationContext.getString(R.string.no_wifi_connection)) + } + !noWifi + } else { + downloadManager.stopDownloads(applicationContext.getString(R.string.no_network_connection)) + false + } + } + } + + companion object { + private const val TAG = "Downloader" + private const val START_EXT_JOB_AFTER = "StartExtJobAfter" + + private val downloadChannel = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val downloadFlow = downloadChannel.asSharedFlow() + + fun start(context: Context, alsoStartExtJob: Boolean = false) { + val request = OneTimeWorkRequestBuilder() + .addTag(TAG) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST).apply { + if (alsoStartExtJob) { + setInputData(workDataOf(START_EXT_JOB_AFTER to true)) + } + } + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request) + } + + fun stop(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(TAG) + } + + fun callListeners(downloading: Boolean? = null, downloadManager: DownloadManager? = null) { + val dManager by lazy { downloadManager ?: Injekt.get() } + downloadChannel.tryEmit(downloading ?: !dManager.isPaused()) + } + + fun isRunning(context: Context): Boolean { + return WorkManager.getInstance(context) + .getWorkInfosForUniqueWork(TAG) + .get() + .let { list -> list.count { it.state == WorkInfo.State.RUNNING } == 1 } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt index 9f4d6076c9..e257084e13 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt @@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.data.download import android.content.Context import com.hippo.unifile.UniFile -import com.jakewharton.rxrelay.BehaviorRelay import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga @@ -49,6 +48,8 @@ class DownloadManager(val context: Context) { */ private val downloader = Downloader(context, provider, cache, sourceManager) + val isRunning: Boolean get() = downloader.isRunning + /** * Queue to delay the deletion of a list of chapters until triggered. */ @@ -60,12 +61,6 @@ class DownloadManager(val context: Context) { val queue: DownloadQueue get() = downloader.queue - /** - * Subject for subscribing to downloader status. - */ - val runningRelay: BehaviorRelay - get() = downloader.runningRelay - /** * Tells the downloader to begin downloads. * @@ -73,7 +68,7 @@ class DownloadManager(val context: Context) { */ fun startDownloads(): Boolean { val hasStarted = downloader.start() - DownloadService.callListeners(hasStarted) + DownloadJob.callListeners(downloadManager = this) return hasStarted } @@ -82,19 +77,14 @@ class DownloadManager(val context: Context) { * * @param reason an optional reason for being stopped, used to notify the user. */ - fun stopDownloads(reason: String? = null) { - downloader.stop(reason) - } - - fun setPlaceholder() { - downloader.setPlaceholder() - } + fun stopDownloads(reason: String? = null) = downloader.stop(reason) /** * Tells the downloader to pause downloads. */ fun pauseDownloads() { downloader.pause() + downloader.stop() } /** @@ -105,7 +95,7 @@ class DownloadManager(val context: Context) { fun clearQueue(isNotification: Boolean = false) { deletePendingDownloads(*downloader.queue.toTypedArray()) downloader.clearQueue(isNotification) - DownloadService.callListeners(false) + DownloadJob.callListeners(false, this) } fun startDownloadNow(chapter: Chapter) { @@ -115,11 +105,11 @@ class DownloadManager(val context: Context) { queue.add(0, download) reorderQueue(queue) if (isPaused()) { - if (DownloadService.isRunning(context)) { + if (DownloadJob.isRunning(context)) { downloader.start() - DownloadService.callListeners(true) + DownloadJob.callListeners(true, this) } else { - DownloadService.start(context) + DownloadJob.start(context) } } } @@ -132,7 +122,7 @@ class DownloadManager(val context: Context) { fun reorderQueue(downloads: List) { val wasPaused = isPaused() if (downloads.isEmpty()) { - DownloadService.stop(context) + DownloadJob.stop(context) downloader.queue.clear() return } @@ -141,11 +131,11 @@ class DownloadManager(val context: Context) { downloader.queue.addAll(downloads) if (!wasPaused) { downloader.start() - DownloadService.callListeners(true) + DownloadJob.callListeners(true, this) } } - fun isPaused() = downloader.isPaused() + fun isPaused() = !downloader.isRunning fun hasQueue() = downloader.queue.isNotEmpty() @@ -171,7 +161,7 @@ class DownloadManager(val context: Context) { addAll(0, downloads) reorderQueue(this) } - if (!DownloadService.isRunning(context)) DownloadService.start(context) + if (!DownloadJob.isRunning(context)) DownloadJob.start(context) } /** @@ -257,19 +247,18 @@ class DownloadManager(val context: Context) { GlobalScope.launch(Dispatchers.IO) { val wasPaused = isPaused() if (filteredChapters.isEmpty()) { - DownloadService.stop(context) - downloader.queue.clear() return@launch } downloader.pause() downloader.queue.remove(filteredChapters) if (!wasPaused && downloader.queue.isNotEmpty()) { downloader.start() - DownloadService.callListeners(true) - } else if (downloader.queue.isEmpty() && DownloadService.isRunning(context)) { - DownloadService.stop(context) + DownloadJob.callListeners(true) + } else if (downloader.queue.isEmpty() && DownloadJob.isRunning(context)) { + DownloadJob.callListeners(false) + DownloadJob.stop(context) } else if (downloader.queue.isEmpty()) { - DownloadService.callListeners(false) + DownloadJob.callListeners(false) downloader.stop() } queue.remove(filteredChapters) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt index 7c3e452d9a..d09795f661 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadNotifier.kt @@ -48,11 +48,6 @@ internal class DownloadNotifier(private val context: Context) { */ var errorThrown = false - /** - * Updated when paused - */ - var paused = false - /** * Shows a notification from this builder. * @@ -70,7 +65,7 @@ internal class DownloadNotifier(private val context: Context) { context.notificationManager.cancel(Notifications.ID_DOWNLOAD_CHAPTER) } - fun setPlaceholder(download: Download?) { + fun setPlaceholder(download: Download?): NotificationCompat.Builder { val context = context.localeContext with(notification) { // Check if first call. @@ -101,22 +96,15 @@ internal class DownloadNotifier(private val context: Context) { "", ) setContentTitle("$title - $chapter".chop(30)) - setContentText( - context.getString(R.string.downloading), - ) + setContentText(context.getString(R.string.downloading)) } else { - setContentTitle( - context.getString( - R.string.downloading, - ), - ) + setContentTitle(context.getString(R.string.downloading)) setContentText(null) } setProgress(0, 0, true) setStyle(null) } - // Displays the progress bar on notification - notification.show() + return notification } /** @@ -197,11 +185,9 @@ internal class DownloadNotifier(private val context: Context) { context.getString(R.string.cancel_all), NotificationReceiver.clearDownloadsPendingBroadcast(context), ) + show() } - // Show notification. - notification.show() - // Reset initial values isDownloading = false } @@ -224,7 +210,7 @@ internal class DownloadNotifier(private val context: Context) { setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) setProgress(0, 0, false) } - notification.show() + notification.show(Notifications.ID_DOWNLOAD_CHAPTER_ERROR) // Reset download information isDownloading = false diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt deleted file mode 100644 index 345f6b3be0..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadService.kt +++ /dev/null @@ -1,267 +0,0 @@ -package eu.kanade.tachiyomi.data.download - -import android.app.Notification -import android.app.Service -import android.content.Context -import android.content.Intent -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest -import android.os.Build -import android.os.IBinder -import android.os.PowerManager -import androidx.core.app.NotificationCompat -import androidx.work.NetworkType -import com.jakewharton.rxrelay.BehaviorRelay -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.library.LibraryUpdateService -import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.extension.ExtensionUpdateJob -import eu.kanade.tachiyomi.util.lang.plusAssign -import eu.kanade.tachiyomi.util.system.acquireWakeLock -import eu.kanade.tachiyomi.util.system.connectivityManager -import eu.kanade.tachiyomi.util.system.isConnectedToWifi -import eu.kanade.tachiyomi.util.system.isOnline -import eu.kanade.tachiyomi.util.system.isServiceRunning -import eu.kanade.tachiyomi.util.system.localeContext -import rx.subscriptions.CompositeSubscription -import uy.kohesive.injekt.injectLazy - -/** - * This service is used to manage the downloader. The system can decide to stop the service, in - * which case the downloader is also stopped. It's also stopped while there's no network available. - * While the downloader is running, a wake lock will be held. - */ -class DownloadService : Service() { - - companion object { - - /** - * Relay used to know when the service is running. - */ - val runningRelay: BehaviorRelay = BehaviorRelay.create(false) - - private val listeners = mutableSetOf() - - fun addListener(listener: DownloadServiceListener) { - listeners.add(listener) - } - - fun removeListener(listener: DownloadServiceListener) { - listeners.remove(listener) - } - - fun callListeners(downloading: Boolean? = null) { - val downloadManager: DownloadManager by injectLazy() - listeners.forEach { - it.downloadStatusChanged(downloading ?: !downloadManager.isPaused()) - } - } - - /** - * Starts this service. - * - * @param context the application context. - */ - fun start(context: Context) { - callListeners() - val intent = Intent(context, DownloadService::class.java) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - context.startService(intent) - } else { - context.startForegroundService(intent) - } - } - - /** - * Stops this service. - * - * @param context the application context. - */ - fun stop(context: Context) { - context.stopService(Intent(context, DownloadService::class.java)) - } - - /** - * Returns the status of the service. - * - * @param context the application context. - * @return true if the service is running, false otherwise. - */ - fun isRunning(context: Context): Boolean { - return context.isServiceRunning(DownloadService::class.java) - } - - private const val STOP_REASON_NO_WIFI = 1 - private const val STOP_REASON_NO_INTERNET = 2 - } - - /** - * Download manager. - */ - private val downloadManager: DownloadManager by injectLazy() - - /** - * Preferences helper. - */ - private val preferences: PreferencesHelper by injectLazy() - - /** - * Wake lock to prevent the device to enter sleep mode. - */ - private lateinit var wakeLock: PowerManager.WakeLock - - /** - * Subscriptions to store while the service is running. - */ - private lateinit var subscriptions: CompositeSubscription - - private var stopReason: Int? = null - - private val networkCallback = object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - onNetworkStateChanged() - } - - override fun onLost(network: Network) { - onNetworkStateChanged() - } - - override fun onCapabilitiesChanged( - network: Network, - networkCapabilities: NetworkCapabilities, - ) { - onNetworkStateChanged() - } - - override fun onUnavailable() { - onNetworkStateChanged() - } - } - - /** - * Called when the service is created. - */ - override fun onCreate() { - super.onCreate() - startForeground(Notifications.ID_DOWNLOAD_CHAPTER, getPlaceholderNotification()) - wakeLock = acquireWakeLock(javaClass.name) - downloadManager.setPlaceholder() - runningRelay.call(true) - subscriptions = CompositeSubscription() - listenDownloaderState() - listenNetworkChanges() - } - - /** - * Called when the service is destroyed. - */ - override fun onDestroy() { - runningRelay.call(false) - subscriptions.unsubscribe() - connectivityManager.unregisterNetworkCallback(networkCallback) - downloadManager.stopDownloads( - when (stopReason) { - STOP_REASON_NO_INTERNET -> getString(R.string.no_network_connection) - STOP_REASON_NO_WIFI -> getString(R.string.no_wifi_connection) - else -> null - }, - ) - callListeners(false) - wakeLock.releaseIfNeeded() - if (LibraryUpdateService.runExtensionUpdatesAfter) { - ExtensionUpdateJob.runJobAgain(this, NetworkType.CONNECTED) - LibraryUpdateService.runExtensionUpdatesAfter = false - } - super.onDestroy() - } - - /** - * Not used. - */ - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - return START_NOT_STICKY - } - - /** - * Not used. - */ - override fun onBind(intent: Intent): IBinder? { - return null - } - - /** - * Listens to network changes. - * - * @see onNetworkStateChanged - */ - private fun listenNetworkChanges() { - onNetworkStateChanged() - val networkChangeFilter = NetworkRequest.Builder().build() - connectivityManager.registerNetworkCallback(networkChangeFilter, networkCallback) - return - } - - /** - * Called when the network state changes. - * - */ - private fun onNetworkStateChanged() { - val manager = connectivityManager - val networkCapabilities = manager.getNetworkCapabilities(manager.activeNetwork) - if (networkCapabilities == null || !isOnline()) { - stopReason = STOP_REASON_NO_INTERNET - stopSelf() - return - } - if (preferences.downloadOnlyOverWifi() && !isConnectedToWifi()) { - stopReason = STOP_REASON_NO_WIFI - stopSelf() - } else { - stopReason = null - val started = downloadManager.startDownloads() - if (!started) stopSelf() - } - } - - /** - * Listens to downloader status. Enables or disables the wake lock depending on the status. - */ - private fun listenDownloaderState() { - subscriptions += downloadManager.runningRelay - .doOnError { } // Swallow wakelock error - .subscribe { running -> - if (running) { - wakeLock.acquireIfNeeded() - } else { - wakeLock.releaseIfNeeded() - } - } - } - - /** - * Releases the wake lock if it's held. - */ - fun PowerManager.WakeLock.releaseIfNeeded() { - if (isHeld) release() - } - - /** - * Acquires the wake lock if it's not held. - */ - fun PowerManager.WakeLock.acquireIfNeeded() { - if (!isHeld) acquire() - } - - private fun getPlaceholderNotification(): Notification { - return NotificationCompat.Builder(this, Notifications.CHANNEL_DOWNLOADER) - .setContentTitle(localeContext.getString(R.string.downloading)) - .build() - } -} - -interface DownloadServiceListener { - fun downloadStatusChanged(downloading: Boolean) -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt index d0b035a0cc..bbe7ab25e4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/Downloader.kt @@ -4,10 +4,11 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Environment +import android.os.Handler +import android.os.Looper import android.provider.Settings import androidx.core.net.toUri import com.hippo.unifile.UniFile -import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.PublishRelay import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.ChapterCache @@ -15,28 +16,38 @@ import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.DownloadQueue -import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.UnmeteredSource import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList import eu.kanade.tachiyomi.util.chapter.ChapterUtil.Companion.preferredChapterName -import eu.kanade.tachiyomi.util.lang.RetryWithDelay -import eu.kanade.tachiyomi.util.lang.plusAssign import eu.kanade.tachiyomi.util.storage.DiskUtil +import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE import eu.kanade.tachiyomi.util.storage.saveTo import eu.kanade.tachiyomi.util.system.ImageUtil +import eu.kanade.tachiyomi.util.system.awaitSingle import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.launchNow +import eu.kanade.tachiyomi.util.system.withIOContext import eu.kanade.tachiyomi.util.system.withUIContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.runBlocking import okhttp3.Response import rx.Observable +import rx.Subscription import rx.android.schedulers.AndroidSchedulers import rx.schedulers.Schedulers -import rx.subscriptions.CompositeSubscription import timber.log.Timber import uy.kohesive.injekt.injectLazy import java.io.BufferedOutputStream @@ -78,37 +89,40 @@ class Downloader( */ val queue = DownloadQueue(store) + private val handler = Handler(Looper.getMainLooper()) + /** * Notifier for the downloader state and progress. */ private val notifier by lazy { DownloadNotifier(context) } /** - * Downloader subscriptions. + * Downloader subscription. */ - private val subscriptions = CompositeSubscription() + private var subscription: Subscription? = null /** * Relay to send a list of downloads to the downloader. */ private val downloadsRelay = PublishRelay.create>() - /** - * Relay to subscribe to the downloader status. - */ - val runningRelay: BehaviorRelay = BehaviorRelay.create(false) - /** * Whether the downloader is running. */ + val isRunning: Boolean + get() = subscription != null + + /** + * Whether the downloader is paused + */ @Volatile - private var isRunning: Boolean = false + var isPaused: Boolean = false init { launchNow { val chapters = async { store.restore() } queue.addAll(chapters.await()) - DownloadService.callListeners() + DownloadJob.callListeners() } } @@ -119,15 +133,16 @@ class Downloader( * @return true if the downloader is started, false otherwise. */ fun start(): Boolean { - if (isRunning || queue.isEmpty()) { - return isRunning + if (subscription != null || queue.isEmpty()) { + return false } - notifier.paused = false - if (!subscriptions.hasSubscriptions()) initializeSubscriptions() + initializeSubscription() val pending = queue.filter { it.status != Download.State.DOWNLOADED } pending.forEach { if (it.status != Download.State.QUEUE) it.status = Download.State.QUEUE } + isPaused = false + downloadsRelay.call(pending) return pending.isNotEmpty() } @@ -136,50 +151,44 @@ class Downloader( * Stops the downloader. */ fun stop(reason: String? = null) { - destroySubscriptions() + destroySubscription() queue .filter { it.status == Download.State.DOWNLOADING } .forEach { it.status = Download.State.ERROR } if (reason != null) { notifier.onWarning(reason) - } else { - if (notifier.paused) { - if (queue.isEmpty()) { - notifier.dismiss() - } else { - notifier.paused = false - notifier.onDownloadPaused() - } - } else { - notifier.dismiss() - } + return } + + DownloadJob.stop(context) + if (isPaused && queue.isNotEmpty()) { + handler.postDelayed({ notifier.onDownloadPaused() }, 150) + } else { + notifier.dismiss() + } + DownloadJob.callListeners(false) + isPaused = false } /** * Pauses the downloader */ fun pause() { - destroySubscriptions() + destroySubscription() queue .filter { it.status == Download.State.DOWNLOADING } .forEach { it.status = Download.State.QUEUE } - notifier.paused = true + isPaused = true } - /** - * Check if downloader is paused - */ - fun isPaused() = !isRunning - /** * Removes everything from the queue. * * @param isNotification value that determines if status is set (needed for view updates) */ fun clearQueue(isNotification: Boolean = false) { - destroySubscriptions() + destroySubscription() // Needed to update the chapter view if (isNotification) { @@ -204,7 +213,7 @@ class Downloader( } queue.remove(manga) if (queue.isEmpty()) { - if (DownloadService.isRunning(context)) DownloadService.stop(context) + if (DownloadJob.isRunning(context)) DownloadJob.stop(context) stop() } notifier.dismiss() @@ -213,19 +222,19 @@ class Downloader( /** * Prepares the subscriptions to start downloading. */ - private fun initializeSubscriptions() { + private fun initializeSubscription() { if (isRunning) return - isRunning = true - runningRelay.call(true) - subscriptions.clear() - subscriptions += downloadsRelay.concatMapIterable { it } + subscription = downloadsRelay.concatMapIterable { it } // Concurrently download from 5 different sources .groupBy { it.source } .flatMap( { bySource -> bySource.concatMap { download -> - downloadChapter(download).subscribeOn(Schedulers.io()) + Observable.fromCallable { + runBlocking { downloadChapter(download) } + download + }.subscribeOn(Schedulers.io()) } }, 5, @@ -237,9 +246,9 @@ class Downloader( completeDownload(it) }, { error -> - DownloadService.stop(context) Timber.e(error) notifier.onError(error.message) + stop() }, ) } @@ -247,12 +256,9 @@ class Downloader( /** * Destroys the downloader subscriptions. */ - private fun destroySubscriptions() { - if (!isRunning) return - isRunning = false - runningRelay.call(false) - - subscriptions.clear() + private fun destroySubscription() { + subscription?.unsubscribe() + subscription = null } /** @@ -263,6 +269,10 @@ class Downloader( * @param autoStart whether to start the downloader after enqueing the chapters. */ fun queueChapters(manga: Manga, chapters: List, autoStart: Boolean) = launchIO { + if (chapters.isEmpty()) { + return@launchIO + } + val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchIO val wasEmpty = queue.isEmpty() // Called in background thread, the operation can be slow with SAF. @@ -304,19 +314,19 @@ class Downloader( notifier.massDownloadWarning() } } - DownloadService.start(context) - } else if (!isRunning && !LibraryUpdateService.isRunning()) { + DownloadJob.start(context) + } else if (!isRunning && !LibraryUpdateJob.isRunning(context)) { notifier.onDownloadPaused() } } } /** - * Returns the observable which downloads a chapter. + * Downloads a chapter. * * @param download the chapter to be downloaded. */ - private fun downloadChapter(download: Download): Observable = Observable.defer { + private suspend fun downloadChapter(download: Download) { val mangaDir = provider.getMangaDir(download.manga, download.source) val availSpace = DiskUtil.getAvailableStorageSpace(mangaDir) @@ -324,7 +334,7 @@ class Downloader( if (availSpace != -1L && availSpace < MIN_DISK_SPACE) { download.status = Download.State.ERROR notifier.onError(context.getString(R.string.couldnt_download_low_space), chapName) - return@defer Observable.just(download) + return } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !Environment.isExternalStorageManager() @@ -340,57 +350,73 @@ class Downloader( download.manga.title, intent, ) - return@defer Observable.just(download) + return } val chapterDirname = provider.getChapterDirName(download.chapter) val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX) - val pageListObservable = if (download.pages == null) { - // Pull page list from network and add them to download object - download.source.fetchPageList(download.chapter) - .map { pages -> - if (pages.isEmpty()) { - throw Exception(context.getString(R.string.no_pages_found)) - } - // Don't trust index from source - val reIndexedPages = pages.mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) } - download.pages = reIndexedPages - reIndexedPages + try { + // If the page list already exists, start from the file + val pageList = download.pages ?: run { + // Otherwise, pull page list from network and add them to download object + val pages = download.source.getPageList(download.chapter) + + if (pages.isEmpty()) { + throw Exception(context.getString(R.string.no_pages_found)) } - } else { - // Or if the page list already exists, start from the file - Observable.just(download.pages!!) - } - - pageListObservable - .doOnNext { _ -> - // Delete all temporary (unfinished) files - tmpDir.listFiles() - ?.filter { it.name!!.endsWith(".tmp") } - ?.forEach { it.delete() } - - download.downloadedImages = 0 - download.status = Download.State.DOWNLOADING + // Don't trust index from source + val reIndexedPages = pages.mapIndexed { index, page -> + Page( + index, + page.url, + page.imageUrl, + page.uri, + ) + } + download.pages = reIndexedPages + reIndexedPages } + + // Delete all temporary (unfinished) files + tmpDir.listFiles() + ?.filter { it.name!!.endsWith(".tmp") } + ?.forEach { it.delete() } + + download.status = Download.State.DOWNLOADING + // Get all the URLs to the source images, fetch pages if necessary - .flatMap { download.source.fetchAllImageUrlsFromPageList(it) } + pageList.filter { it.imageUrl.isNullOrEmpty() }.forEach { page -> + page.status = Page.State.LOAD_PAGE + try { + page.imageUrl = download.source.fetchImageUrl(page).awaitSingle() + } catch (e: Throwable) { + page.status = Page.State.ERROR + } + } + // Start downloading images, consider we can have downloaded images already // Concurrently do 2 pages at a time - .flatMap({ page -> getOrDownloadImage(page, download, tmpDir).subscribeOn(Schedulers.io()) }, 2) - .onBackpressureLatest() - // Do when page is downloaded. - .doOnNext { notifier.onProgressChange(download) } - .toList() - .map { download } + pageList.asFlow() + .flatMapMerge(concurrency = 2) { page -> + flow { + withIOContext { getOrDownloadImage(page, download, tmpDir) } + emit(page) + }.flowOn(Dispatchers.IO) + } + .collect { + // Do when page is downloaded. + notifier.onProgressChange(download) + } + // Do after download completes - .doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) } + ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) + } catch (error: Throwable) { + if (error is CancellationException) throw error // If the page list threw, it will resume here - .onErrorReturn { error -> - Timber.e(error) - download.status = Download.State.ERROR - notifier.onError(error.message, chapName, download.manga.title) - download - } + Timber.e(error) + download.status = Download.State.ERROR + notifier.onError(error.message, chapName, download.manga.title) + } } /** @@ -401,115 +427,122 @@ class Downloader( * @param download the download of the page. * @param tmpDir the temporary directory of the download. */ - private fun getOrDownloadImage( + private suspend fun getOrDownloadImage( page: Page, download: Download, tmpDir: UniFile, - ): Observable { + ) { // If the image URL is empty, do nothing if (page.imageUrl == null) { - return Observable.just(page) + return } val digitCount = (download.pages?.size ?: 0).toString().length.coerceAtLeast(3) - val filename = String.format("%0${digitCount}d", page.number) val tmpFile = tmpDir.findFile("$filename.tmp") - // Delete temp file if it exists. + // Delete temp file if it exists tmpFile?.delete() - // Try to find the image file. - val imageFile = tmpDir.listFiles()!!.find { it.name!!.startsWith("$filename.") || it.name!!.contains("${filename}__001") } - - // If the image is already downloaded, do nothing. Otherwise download from network - val pageObservable = when { - imageFile != null -> Observable.just(imageFile) - chapterCache.isImageInCache(page.imageUrl!!) -> moveImageFromCache(chapterCache.getImageFile(page.imageUrl!!), tmpDir, filename) - else -> downloadImage(page, download.source, tmpDir, filename) - } + // Try to find the image file + val imageFile = tmpDir.listFiles()?.find { it.name!!.startsWith("$filename.") || it.name!!.startsWith("${filename}__001") } val chapName = download.chapter.preferredChapterName(context, download.manga, preferences) - return pageObservable + try { + // If the image is already downloaded, do nothing. Otherwise download from network + val file = when { + imageFile != null -> imageFile + chapterCache.isImageInCache(page.imageUrl!!) -> moveImageFromCache( + chapterCache.getImageFile( + page.imageUrl!!, + ), + tmpDir, + filename, + ) + else -> downloadImage(page, download.source, tmpDir, filename) + } + // When the page is ready, set page path, progress (just in case) and status - .doOnNext { file -> - val success = splitTallImageIfNeeded(page, tmpDir) - if (success.not()) { - notifier.onError(context.getString(R.string.download_notifier_split_failed), chapName, download.manga.title) - } - page.uri = file.uri - page.progress = 100 - download.downloadedImages++ - page.status = Page.State.READY + val success = splitTallImageIfNeeded(page, tmpDir) + if (!success) { + notifier.onError( + context.getString(R.string.download_notifier_split_failed), + chapName, + download.manga.title, + ) } - .map { page } + page.uri = file.uri + page.progress = 100 + page.status = Page.State.READY + } catch (e: Throwable) { + if (e is CancellationException) throw e // Mark this page as error and allow to download the remaining - .onErrorReturn { - page.progress = 0 - page.status = Page.State.ERROR - notifier.onError(it.message, chapName, download.manga.title) - page - } + page.progress = 0 + page.status = Page.State.ERROR + notifier.onError(e.message, chapName, download.manga.title) + } } /** - * Returns the observable which downloads the image from network. + * Downloads the image from network to a file in tmpDir. * * @param page the page to download. * @param source the source of the page. * @param tmpDir the temporary directory of the download. * @param filename the filename of the image. */ - private fun downloadImage( + private suspend fun downloadImage( page: Page, source: HttpSource, tmpDir: UniFile, filename: String, - ): Observable { + ): UniFile { page.status = Page.State.DOWNLOAD_IMAGE page.progress = 0 - return source.fetchImage(page) - .map { response -> - val file = tmpDir.createFile("$filename.tmp") - try { - response.body.source().saveTo(file.openOutputStream()) - val extension = getImageExtension(response, file) - file.renameTo("$filename.$extension") - } catch (e: Exception) { - response.close() - file.delete() - throw e - } - file + return flow { + val response = source.getImage(page) + val file = tmpDir.createFile("$filename.tmp") + try { + response.body.source().saveTo(file.openOutputStream()) + val extension = getImageExtension(response, file) + file.renameTo("$filename.$extension") + } catch (e: Exception) { + response.close() + file.delete() + throw e } + emit(file) + } // Retry 3 times, waiting 2, 4 and 8 seconds between attempts. - .retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline())) + .retryWhen { _, attempt -> + if (attempt < 3) { + delay((2L shl attempt.toInt()) * 1000) + true + } else { + false + } + } + .first() } /** - * Return the observable which copies the image from cache. + * Copies the image from cache to file in tmpDir. * * @param cacheFile the file from cache. * @param tmpDir the temporary directory of the download. * @param filename the filename of the image. */ - private fun moveImageFromCache( - cacheFile: File, - tmpDir: UniFile, - filename: String, - ): Observable { - return Observable.just(cacheFile).map { - val tmpFile = tmpDir.createFile("$filename.tmp") - cacheFile.inputStream().use { input -> - tmpFile.openOutputStream().use { output -> - input.copyTo(output) - } + private fun moveImageFromCache(cacheFile: File, tmpDir: UniFile, filename: String): UniFile { + val tmpFile = tmpDir.createFile("$filename.tmp") + cacheFile.inputStream().use { input -> + tmpFile.openOutputStream().use { output -> + input.copyTo(output) } - val extension = ImageUtil.findImageType(cacheFile.inputStream()) ?: return@map tmpFile - tmpFile.renameTo("$filename.${extension.extension}") - cacheFile.delete() - tmpFile } + val extension = ImageUtil.findImageType(cacheFile.inputStream()) ?: return tmpFile + tmpFile.renameTo("$filename.${extension.extension}") + cacheFile.delete() + return tmpFile } /** @@ -564,11 +597,33 @@ class Downloader( tmpDir: UniFile, dirname: String, ) { - // Ensure that the chapter folder has all the images. - val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") || (it.name!!.contains("__") && !it.name!!.contains("__001.jpg")) } + // Page list hasn't been initialized + val downloadPageCount = download.pages?.size ?: return + // Ensure that all pages has been downloaded + if (download.downloadedImages < downloadPageCount) return + // Ensure that the chapter folder has all the pages + val downloadedImagesCount = tmpDir.listFiles().orEmpty().count { + val fileName = it.name.orEmpty() + when { + fileName in listOf(/*COMIC_INFO_FILE, */NOMEDIA_FILE) -> false + fileName.endsWith(".tmp") -> false + // Only count the first split page and not the others + fileName.contains("__") && !fileName.endsWith("__001.jpg") -> false + else -> true + } + } - download.status = if (downloadedImages.size == download.pages!!.size) { - // Only rename the directory if it's downloaded. + download.status = if (downloadedImagesCount == downloadPageCount) { + // TODO: Uncomment when #8537 is resolved +// val chapterUrl = download.source.getChapterUrl(download.chapter) +// createComicInfoFile( +// tmpDir, +// download.manga, +// download.chapter.toDomainChapter()!!, +// chapterUrl, +// ) + + // Only rename the directory if it's downloaded if (preferences.saveChaptersAsCBZ().get()) { archiveChapter(mangaDir, dirname, tmpDir) } else { @@ -592,7 +647,7 @@ class Downloader( dirname: String, tmpDir: UniFile, ) { - val zip = mangaDir.createFile("$dirname.cbz.tmp") + val zip = mangaDir.createFile("$dirname.cbz$TMP_DIR_SUFFIX") ZipOutputStream(BufferedOutputStream(zip.openOutputStream())).use { zipOut -> zipOut.setMethod(ZipEntry.STORED) @@ -618,24 +673,43 @@ class Downloader( tmpDir.delete() } +// /** +// * Creates a ComicInfo.xml file inside the given directory. +// * +// * @param dir the directory in which the ComicInfo file will be generated. +// * @param manga the manga. +// * @param chapter the chapter. +// * @param chapterUrl the resolved URL for the chapter. +// */ +// private fun createComicInfoFile( +// dir: UniFile, +// manga: Manga, +// chapter: Chapter, +// chapterUrl: String, +// ) { +// val comicInfo = getComicInfo(manga, chapter, chapterUrl) +// val comicInfoString = xml.encodeToString(ComicInfo.serializer(), comicInfo) +// // Remove the old file +// dir.findFile(COMIC_INFO_FILE)?.delete() +// dir.createFile(COMIC_INFO_FILE).openOutputStream().use { +// it.write(comicInfoString.toByteArray()) +// } +// } + /** * Completes a download. This method is called in the main thread. */ private fun completeDownload(download: Download) { // Delete successful downloads from queue if (download.status == Download.State.DOWNLOADED) { - // remove downloaded chapter from queue + // Remove downloaded chapter from queue queue.remove(download) } if (areAllDownloadsFinished()) { - DownloadService.stop(context) + stop() } } - fun setPlaceholder() { - notifier.setPlaceholder(queue.firstOrNull()) - } - /** * Returns true if all the queued downloads are in DOWNLOADED or ERROR state. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt index b9886e092d..7ea71f119a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/model/Download.kt @@ -11,11 +11,11 @@ class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) { var pages: List? = null - @Volatile @Transient - var totalProgress: Int = 0 + val totalProgress: Int + get() = pages?.sumOf(Page::progress) ?: 0 - @Volatile @Transient - var downloadedImages: Int = 0 + val downloadedImages: Int + get() = pages?.count { it.status == Page.State.READY } ?: 0 @Volatile @Transient var status: State = State.default diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt index 0a8e7f7179..96cb1acb4a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt @@ -1,38 +1,638 @@ package eu.kanade.tachiyomi.data.library import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo import androidx.work.WorkManager -import androidx.work.Worker +import androidx.work.WorkQuery import androidx.work.WorkerParameters +import coil.Coil +import coil.request.CachePolicy +import coil.request.ImageRequest +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.LibraryManga +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.download.DownloadJob +import eu.kanade.tachiyomi.data.download.DownloadManager +import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.DEVICE_BATTERY_NOT_LOW import eu.kanade.tachiyomi.data.preference.DEVICE_CHARGING import eu.kanade.tachiyomi.data.preference.DEVICE_ONLY_ON_WIFI +import eu.kanade.tachiyomi.data.preference.MANGA_HAS_UNREAD +import eu.kanade.tachiyomi.data.preference.MANGA_NON_COMPLETED +import eu.kanade.tachiyomi.data.preference.MANGA_NON_READ import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.track.EnhancedTrackService +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.extension.ExtensionUpdateJob +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.UnmeteredSource +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.model.UpdateStrategy +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource +import eu.kanade.tachiyomi.util.chapter.syncChaptersWithTrackServiceTwoWay +import eu.kanade.tachiyomi.util.manga.MangaShortcutManager +import eu.kanade.tachiyomi.util.shouldDownloadNewChapters +import eu.kanade.tachiyomi.util.storage.getUriCompat +import eu.kanade.tachiyomi.util.system.createFileInCacheDir import eu.kanade.tachiyomi.util.system.isConnectedToWifi +import eu.kanade.tachiyomi.util.system.localeContext +import eu.kanade.tachiyomi.util.system.tryToSetForeground +import eu.kanade.tachiyomi.util.system.withIOContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import java.io.File +import java.lang.ref.WeakReference +import java.util.Date +import java.util.concurrent.CancellationException import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger class LibraryUpdateJob(private val context: Context, workerParams: WorkerParameters) : - Worker(context, workerParams) { + CoroutineWorker(context, workerParams) { - override fun doWork(): Result { - val preferences = Injekt.get() - return if (requiresWifiConnection(preferences) && !context.isConnectedToWifi()) { - Result.failure() - } else if (LibraryUpdateService.start(context)) { - Result.success() - } else { - Result.failure() + private val db: DatabaseHelper = Injekt.get() + private val coverCache: CoverCache = Injekt.get() + private val sourceManager: SourceManager = Injekt.get() + private val preferences: PreferencesHelper = Injekt.get() + private val downloadManager: DownloadManager = Injekt.get() + private val trackManager: TrackManager = Injekt.get() + private val mangaShortcutManager: MangaShortcutManager = Injekt.get() + + private var extraDeferredJobs = mutableListOf>() + + private val extraScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val emitScope = MainScope() + + private val mangaToUpdate = mutableListOf() + + private val mangaToUpdateMap = mutableMapOf>() + + private val categoryIds = mutableSetOf() + + // List containing new updates + private val newUpdates = mutableMapOf>() + + // List containing failed updates + private val failedUpdates = mutableMapOf() + + // List containing skipped updates + private val skippedUpdates = mutableMapOf() + + val count = AtomicInteger(0) + + // Boolean to determine if user wants to automatically download new chapters. + private val downloadNew: Boolean = preferences.downloadNewChapters().get() + + // Boolean to determine if DownloadManager has downloads + private var hasDownloads = false + + private val requestSemaphore = Semaphore(5) + + // For updates delete removed chapters if not preference is set as well + private val deleteRemoved by lazy { preferences.deleteRemovedChapters().get() != 1 } + + private val notifier = LibraryUpdateNotifier(context.localeContext) + + override suspend fun doWork(): Result { + if (tags.contains(WORK_NAME_AUTO)) { + val preferences = Injekt.get() + val restrictions = preferences.libraryUpdateDeviceRestriction().get() + if ((DEVICE_ONLY_ON_WIFI in restrictions) && !context.isConnectedToWifi()) { + return Result.failure() + } + + // Find a running manual worker. If exists, try again later + if (instance != null) { + return Result.retry() + } } + + tryToSetForeground() + + instance = WeakReference(this) + + val target = inputData.getString(KEY_TARGET)?.let { Target.valueOf(it) } ?: Target.CHAPTERS + + // If this is a chapter update, set the last update time to now + if (target == Target.CHAPTERS) { + preferences.libraryUpdateLastTimestamp().set(Date().time) + } + + val savedMangasList = inputData.getLongArray(KEY_MANGAS)?.asList() + + val mangaList = ( + if (savedMangasList != null) { + val mangas = db.getLibraryMangas().executeAsBlocking().filter { + it.id in savedMangasList + }.distinctBy { it.id } + val categoryId = inputData.getInt(KEY_CATEGORY, -1) + if (categoryId > -1) categoryIds.add(categoryId) + mangas + } else { + getMangaToUpdate() + } + ).sortedBy { it.title } + + return withIOContext { + try { + launchTarget(target, mangaList) + Result.success() + } catch (e: Exception) { + if (e is CancellationException) { + // Assume success although cancelled + finishUpdates(true) + Result.success() + } else { + Timber.e(e) + Result.failure() + } + } finally { + instance = null + sendUpdate(null) + notifier.cancelProgressNotification() + } + } + } + + private suspend fun launchTarget(target: Target, mangaToAdd: List) { + if (target == Target.CHAPTERS) { + sendUpdate(STARTING_UPDATE_SOURCE) + } + when (target) { + Target.CHAPTERS -> updateChaptersJob(filterMangaToUpdate(mangaToAdd)) + Target.DETAILS -> updateDetails(mangaToAdd) + else -> updateTrackings(mangaToAdd) + } + } + + private suspend fun sendUpdate(mangaId: Long?) { + if (isStopped) { + updateMutableFlow.tryEmit(mangaId) + } else { + emitScope.launch { updateMutableFlow.emit(mangaId) } + } + } + + private suspend fun updateChaptersJob(mangaToAdd: List) { + // Initialize the variables holding the progress of the updates. + mangaToUpdate.addAll(mangaToAdd) + mangaToUpdateMap.putAll(mangaToAdd.groupBy { it.source }) + checkIfMassiveUpdate() + coroutineScope { + val list = mangaToUpdateMap.keys.map { source -> + async { + try { + requestSemaphore.withPermit { updateMangaInSource(source) } + } catch (e: Exception) { + Timber.e(e) + false + } + } + } + val results = list.awaitAll() + if (!hasDownloads) { + hasDownloads = results.any { it } + } + finishUpdates() + } + } + + /** + * Method that updates the details of the given list of manga. It's called in a background + * thread, so it's safe to do heavy operations or network calls here. + * + * @param mangaToUpdate the list to update + */ + private suspend fun updateDetails(mangaToUpdate: List) = coroutineScope { + // Initialize the variables holding the progress of the updates. + val count = AtomicInteger(0) + val asyncList = mangaToUpdate.groupBy { it.source }.values.map { list -> + async { + requestSemaphore.withPermit { + list.forEach { manga -> + ensureActive() + val source = sourceManager.get(manga.source) as? HttpSource ?: return@async + notifier.showProgressNotification( + manga, + count.andIncrement, + mangaToUpdate.size, + ) + ensureActive() + val networkManga = try { + source.getMangaDetails(manga.copy()) + } catch (e: java.lang.Exception) { + Timber.e(e) + null + } + if (networkManga != null) { + val thumbnailUrl = manga.thumbnail_url + manga.copyFrom(networkManga) + manga.initialized = true + if (thumbnailUrl != manga.thumbnail_url) { + coverCache.deleteFromCache(thumbnailUrl) + // load new covers in background + val request = + ImageRequest.Builder(context).data(manga) + .memoryCachePolicy(CachePolicy.DISABLED).build() + Coil.imageLoader(context).execute(request) + } else { + val request = + ImageRequest.Builder(context).data(manga) + .memoryCachePolicy(CachePolicy.DISABLED) + .diskCachePolicy(CachePolicy.WRITE_ONLY) + .build() + Coil.imageLoader(context).execute(request) + } + db.insertManga(manga).executeAsBlocking() + } + } + } + } + } + asyncList.awaitAll() + notifier.cancelProgressNotification() + } + + /** + * Method that updates the metadata of the connected tracking services. It's called in a + * background thread, so it's safe to do heavy operations or network calls here. + */ + + private suspend fun updateTrackings(mangaToUpdate: List) { + // Initialize the variables holding the progress of the updates. + var count = 0 + + val loggedServices = trackManager.services.filter { it.isLogged } + + mangaToUpdate.forEach { manga -> + notifier.showProgressNotification(manga, count++, mangaToUpdate.size) + + val tracks = db.getTracks(manga).executeAsBlocking() + + tracks.forEach { track -> + val service = trackManager.getService(track.sync_id) + if (service != null && service in loggedServices) { + try { + val newTrack = service.refresh(track) + db.insertTrack(newTrack).executeAsBlocking() + + if (service is EnhancedTrackService) { + syncChaptersWithTrackServiceTwoWay(db, db.getChapters(manga).executeAsBlocking(), track, service) + } + } catch (e: Exception) { + Timber.e(e) + } + } + } + } + notifier.cancelProgressNotification() + } + + private suspend fun finishUpdates(wasStopped: Boolean = false) { + if (!wasStopped && !isStopped) { + extraDeferredJobs.awaitAll() + } + if (newUpdates.isNotEmpty()) { + notifier.showResultNotification(newUpdates) + if (!wasStopped && preferences.refreshCoversToo().get() && !isStopped) { + updateDetails(newUpdates.keys.toList()) + notifier.cancelProgressNotification() + if (downloadNew && hasDownloads) { + DownloadJob.start(context, runExtensionUpdatesAfter) + runExtensionUpdatesAfter = false + } + } else if (downloadNew && hasDownloads) { + DownloadJob.start(applicationContext, runExtensionUpdatesAfter) + runExtensionUpdatesAfter = false + } + } + newUpdates.clear() + if (skippedUpdates.isNotEmpty() && Notifications.isNotificationChannelEnabled(context, Notifications.CHANNEL_LIBRARY_SKIPPED)) { + val skippedFile = writeErrorFile( + skippedUpdates, + "skipped", + context.getString(R.string.learn_why) + " - " + LibraryUpdateNotifier.HELP_SKIPPED_URL, + ).getUriCompat(context) + notifier.showUpdateSkippedNotification(skippedUpdates.map { it.key.title }, skippedFile) + } + if (failedUpdates.isNotEmpty() && Notifications.isNotificationChannelEnabled(context, Notifications.CHANNEL_LIBRARY_ERROR)) { + val errorFile = writeErrorFile(failedUpdates).getUriCompat(context) + notifier.showUpdateErrorNotification(failedUpdates.map { it.key.title }, errorFile) + } + mangaShortcutManager.updateShortcuts(context) + failedUpdates.clear() + notifier.cancelProgressNotification() + if (runExtensionUpdatesAfter && !DownloadJob.isRunning(context)) { + ExtensionUpdateJob.runJobAgain(context, NetworkType.CONNECTED) + runExtensionUpdatesAfter = false + } + } + + private fun checkIfMassiveUpdate() { + val largestSourceSize = mangaToUpdate + .groupBy { it.source } + .filterKeys { sourceManager.get(it) !is UnmeteredSource } + .maxOfOrNull { it.value.size } ?: 0 + if (largestSourceSize > MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD) { + notifier.showQueueSizeWarningNotification() + } + } + + private suspend fun updateMangaInSource(source: Long): Boolean { + if (mangaToUpdateMap[source] == null) return false + var count = 0 + var hasDownloads = false + val httpSource = sourceManager.get(source) as? HttpSource ?: return false + while (count < mangaToUpdateMap[source]!!.size) { + val manga = mangaToUpdateMap[source]!![count] + val shouldDownload = manga.shouldDownloadNewChapters(db, preferences) + if (updateMangaChapters(manga, this.count.andIncrement, httpSource, shouldDownload)) { + hasDownloads = true + } + count++ + } + mangaToUpdateMap[source] = emptyList() + return hasDownloads + } + + private suspend fun updateMangaChapters( + manga: LibraryManga, + progress: Int, + source: HttpSource, + shouldDownload: Boolean, + ): Boolean = coroutineScope { + try { + var hasDownloads = false + ensureActive() + notifier.showProgressNotification(manga, progress, mangaToUpdate.size) + val fetchedChapters = source.getChapterList(manga) + + if (fetchedChapters.isNotEmpty()) { + val newChapters = syncChaptersWithSource(db, fetchedChapters, manga, source) + if (newChapters.first.isNotEmpty()) { + if (shouldDownload) { + downloadChapters( + manga, + newChapters.first.sortedBy { it.chapter_number }, + ) + hasDownloads = true + } + newUpdates[manga] = + newChapters.first.sortedBy { it.chapter_number }.toTypedArray() + } + if (deleteRemoved && newChapters.second.isNotEmpty()) { + val removedChapters = newChapters.second.filter { + downloadManager.isChapterDownloaded(it, manga) && + newChapters.first.none { newChapter -> + newChapter.chapter_number == it.chapter_number && it.scanlator.isNullOrBlank() + } + } + if (removedChapters.isNotEmpty()) { + downloadManager.deleteChapters(removedChapters, manga, source) + } + } + if (newChapters.first.size + newChapters.second.size > 0) { + sendUpdate(manga.id) + } + } + return@coroutineScope hasDownloads + } catch (e: Exception) { + if (e !is CancellationException) { + failedUpdates[manga] = e.message + Timber.e("Failed updating: ${manga.title}: $e") + } + return@coroutineScope false + } + } + + private fun downloadChapters(manga: Manga, chapters: List) { + // We don't want to start downloading while the library is updating, because websites + // may don't like it and they could ban the user. + downloadManager.downloadChapters(manga, chapters, false) + } + + private fun filterMangaToUpdate(mangaToAdd: List): List { + val restrictions = preferences.libraryUpdateMangaRestriction().get() + return mangaToAdd.filter { manga -> + when { + MANGA_NON_COMPLETED in restrictions && manga.status == SManga.COMPLETED -> { + skippedUpdates[manga] = context.getString(R.string.skipped_reason_completed) + } + MANGA_HAS_UNREAD in restrictions && manga.unread != 0 -> { + skippedUpdates[manga] = context.getString(R.string.skipped_reason_not_caught_up) + } + MANGA_NON_READ in restrictions && manga.totalChapters > 0 && !manga.hasRead -> { + skippedUpdates[manga] = context.getString(R.string.skipped_reason_not_started) + } + manga.update_strategy != UpdateStrategy.ALWAYS_UPDATE -> { + skippedUpdates[manga] = context.getString(R.string.skipped_reason_not_always_update) + } + else -> { + return@filter true + } + } + return@filter false + } + } + + private fun getMangaToUpdate(): List { + val categoryId = inputData.getInt(KEY_CATEGORY, -1) + return getMangaToUpdate(categoryId) + } + + /** + * Returns the list of manga to be updated. + * + * @param categoryId the category to update + * @return a list of manga to update + */ + private fun getMangaToUpdate(categoryId: Int): List { + val libraryManga = db.getLibraryMangas().executeAsBlocking() + + val listToUpdate = if (categoryId != -1) { + categoryIds.add(categoryId) + libraryManga.filter { it.category == categoryId } + } else { + val categoriesToUpdate = + preferences.libraryUpdateCategories().get().map(String::toInt) + if (categoriesToUpdate.isNotEmpty()) { + categoryIds.addAll(categoriesToUpdate) + libraryManga.filter { it.category in categoriesToUpdate }.distinctBy { it.id } + } else { + categoryIds.addAll(db.getCategories().executeAsBlocking().mapNotNull { it.id } + 0) + libraryManga.distinctBy { it.id } + } + } + + val categoriesToExclude = + preferences.libraryUpdateCategoriesExclude().get().map(String::toInt) + val listToExclude = if (categoriesToExclude.isNotEmpty() && categoryId == -1) { + libraryManga.filter { it.category in categoriesToExclude }.toSet() + } else { + emptySet() + } + + return listToUpdate.minus(listToExclude) + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = notifier.progressNotificationBuilder.build() + val id = Notifications.ID_LIBRARY_PROGRESS + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(id, notification) + } + } + + /** + * Writes basic file of update errors to cache dir. + */ + private fun writeErrorFile(errors: Map, fileName: String = "errors", additionalInfo: String? = null): File { + try { + if (errors.isNotEmpty()) { + val file = context.createFileInCacheDir("tachiyomi_update_$fileName.txt") + file.bufferedWriter().use { out -> + additionalInfo?.let { out.write("$it\n\n") } + // Error file format: + // ! Error + // # Source + // - Manga + errors.toList().groupBy({ it.second }, { it.first }).forEach { (error, mangas) -> + out.write("! ${error}\n") + mangas.groupBy { it.source }.forEach { (srcId, mangas) -> + val source = sourceManager.getOrStub(srcId) + out.write(" # $source\n") + mangas.forEach { + out.write(" - ${it.title}\n") + } + } + } + } + return file + } + } catch (e: Exception) { + // Empty + } + return File("") + } + + private fun addMangaToQueue(categoryId: Int, manga: List) { + val mangas = filterMangaToUpdate(manga).sortedBy { it.title } + categoryIds.add(categoryId) + addManga(mangas) + } + + private fun addCategory(categoryId: Int) { + val mangas = filterMangaToUpdate(getMangaToUpdate(categoryId)).sortedBy { it.title } + categoryIds.add(categoryId) + addManga(mangas) + } + + private fun addManga(mangaToAdd: List) { + val distinctManga = mangaToAdd.filter { it !in mangaToUpdate } + mangaToUpdate.addAll(distinctManga) + checkIfMassiveUpdate() + distinctManga.groupBy { it.source }.forEach { + // if added queue items is a new source not in the async list or an async list has + // finished running + if (mangaToUpdateMap[it.key].isNullOrEmpty()) { + mangaToUpdateMap[it.key] = it.value + extraScope.launch { + extraDeferredJobs.add( + async(Dispatchers.IO) { + val hasDLs = try { + requestSemaphore.withPermit { updateMangaInSource(it.key) } + } catch (e: Exception) { + false + } + if (!hasDownloads) { + hasDownloads = hasDLs + } + }, + ) + } + } else { + val list = mangaToUpdateMap[it.key] ?: emptyList() + mangaToUpdateMap[it.key] = (list + it.value) + } + } + } + + enum class Target { + + CHAPTERS, // Manga chapters + + DETAILS, // Manga metadata + + TRACKING, // Tracking metadata } companion object { private const val TAG = "LibraryUpdate" + private const val WORK_NAME_AUTO = "LibraryUpdate-auto" + private const val WORK_NAME_MANUAL = "LibraryUpdate-manual" + + private const val ERROR_LOG_HELP_URL = "https://tachiyomi.org/help/guides/troubleshooting" + + private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60 + + /** + * Key for category to update. + */ + private const val KEY_CATEGORY = "category" + const val STARTING_UPDATE_SOURCE = -5L + + /** + * Key that defines what should be updated. + */ + private const val KEY_TARGET = "target" + + private const val KEY_MANGAS = "mangas" + + private var instance: WeakReference? = null + + val updateMutableFlow = MutableSharedFlow( + extraBufferCapacity = 10, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val updateFlow = updateMutableFlow.asSharedFlow() + + private var runExtensionUpdatesAfter = false + + fun runExtensionUpdatesAfterJob() { runExtensionUpdatesAfter = true } fun setupTask(context: Context, prefInterval: Int? = null) { val preferences = Injekt.get() @@ -53,18 +653,89 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet TimeUnit.MINUTES, ) .addTag(TAG) + .addTag(WORK_NAME_AUTO) .setConstraints(constraints) .build() - WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request) + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME_AUTO, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + request, + ) } else { - WorkManager.getInstance(context).cancelAllWorkByTag(TAG) + WorkManager.getInstance(context).cancelAllWorkByTag(WORK_NAME_AUTO) } } - fun requiresWifiConnection(preferences: PreferencesHelper): Boolean { - val restrictions = preferences.libraryUpdateDeviceRestriction().get() - return DEVICE_ONLY_ON_WIFI in restrictions + fun cancelAllWorks(context: Context) { + WorkManager.getInstance(context).cancelAllWorkByTag(TAG) + } + + fun isRunning(context: Context): Boolean { + val list = WorkManager.getInstance(context).getWorkInfosByTag(TAG).get() + return list.any { it.state == WorkInfo.State.RUNNING } + } + + fun categoryInQueue(id: Int?) = instance?.get()?.categoryIds?.contains(id) ?: false + + fun startNow( + context: Context, + category: Category? = null, + target: Target = Target.CHAPTERS, + mangaToUse: List? = null, + ): Boolean { + if (isRunning(context)) { + if (target == Target.CHAPTERS) { + category?.id?.let { + if (mangaToUse != null) { + instance?.get()?.addMangaToQueue(it, mangaToUse) + } else { + instance?.get()?.addCategory(it) + } + } + } + // Already running either as a scheduled or manual job + return false + } + + val builder = Data.Builder() + builder.putString(KEY_TARGET, target.name) + category?.id?.let { id -> + builder.putInt(KEY_CATEGORY, id) + if (mangaToUse != null) { + builder.putLongArray( + KEY_MANGAS, + mangaToUse.mapNotNull { it.id }.toLongArray(), + ) + } + } + val inputData = builder.build() + val request = OneTimeWorkRequestBuilder() + .addTag(TAG) + .addTag(WORK_NAME_MANUAL) + .setInputData(inputData) + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, request) + + return true + } + + fun stop(context: Context) { + val wm = WorkManager.getInstance(context) + val workQuery = WorkQuery.Builder.fromTags(listOf(TAG)) + .addStates(listOf(WorkInfo.State.RUNNING)) + .build() + wm.getWorkInfos(workQuery).get() + // Should only return one work but just in case + .forEach { + wm.cancelWorkById(it.id) + + // Re-enqueue cancelled scheduled work + if (it.tags.contains(WORK_NAME_AUTO)) { + setupTask(context) + } + } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt index 04cd7a3769..0a5111293e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt @@ -1,12 +1,15 @@ package eu.kanade.tachiyomi.data.library +import android.Manifest import android.app.Notification import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.graphics.BitmapFactory import android.graphics.drawable.BitmapDrawable import android.net.Uri +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -56,11 +59,12 @@ class LibraryUpdateNotifier(private val context: Context) { */ val progressNotificationBuilder by lazy { context.notificationBuilder(Notifications.CHANNEL_LIBRARY_PROGRESS) { - setContentTitle(context.getString(R.string.app_name)) + setContentTitle(context.getString(R.string.updating_library)) setSmallIcon(R.drawable.ic_refresh_24dp) setLargeIcon(notificationBitmap) setOngoing(true) setOnlyAlertOnce(true) + setProgress(0, 0, true) color = ContextCompat.getColor(context, R.color.secondaryTachiyomi) addAction(R.drawable.ic_close_24dp, context.getString(android.R.string.cancel), cancelIntent) } @@ -245,6 +249,11 @@ class LibraryUpdateNotifier(private val context: Context) { } NotificationManagerCompat.from(context).apply { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + return@apply + } notify( Notifications.ID_NEW_CHAPTERS, context.notification(Notifications.CHANNEL_NEW_CHAPTERS) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt deleted file mode 100644 index a01b94b53b..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt +++ /dev/null @@ -1,712 +0,0 @@ -package eu.kanade.tachiyomi.data.library - -import android.app.Service -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.IBinder -import android.os.PowerManager -import androidx.work.NetworkType -import coil.Coil -import coil.request.CachePolicy -import coil.request.ImageRequest -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.cache.CoverCache -import eu.kanade.tachiyomi.data.database.DatabaseHelper -import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.database.models.Chapter -import eu.kanade.tachiyomi.data.database.models.LibraryManga -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.download.DownloadManager -import eu.kanade.tachiyomi.data.download.DownloadService -import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start -import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.data.preference.MANGA_HAS_UNREAD -import eu.kanade.tachiyomi.data.preference.MANGA_NON_COMPLETED -import eu.kanade.tachiyomi.data.preference.MANGA_NON_READ -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.track.EnhancedTrackService -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.extension.ExtensionUpdateJob -import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.source.UnmeteredSource -import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.source.model.UpdateStrategy -import eu.kanade.tachiyomi.source.online.HttpSource -import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource -import eu.kanade.tachiyomi.util.chapter.syncChaptersWithTrackServiceTwoWay -import eu.kanade.tachiyomi.util.manga.MangaShortcutManager -import eu.kanade.tachiyomi.util.shouldDownloadNewChapters -import eu.kanade.tachiyomi.util.storage.getUriCompat -import eu.kanade.tachiyomi.util.system.acquireWakeLock -import eu.kanade.tachiyomi.util.system.createFileInCacheDir -import eu.kanade.tachiyomi.util.system.localeContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withContext -import timber.log.Timber -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.io.File -import java.util.Date -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger - -/** - * This class will take care of updating the chapters of the manga from the library. It can be - * started calling the [start] method. If it's already running, it won't do anything. - * While the library is updating, a [PowerManager.WakeLock] will be held until the update is - * completed, preventing the device from going to sleep mode. A notification will display the - * progress of the update, and if case of an unexpected error, this service will be silently - * destroyed. - */ -class LibraryUpdateService( - val db: DatabaseHelper = Injekt.get(), - val coverCache: CoverCache = Injekt.get(), - val sourceManager: SourceManager = Injekt.get(), - val preferences: PreferencesHelper = Injekt.get(), - val downloadManager: DownloadManager = Injekt.get(), - val trackManager: TrackManager = Injekt.get(), - private val mangaShortcutManager: MangaShortcutManager = Injekt.get(), -) : Service() { - - /** - * Wake lock that will be held until the service is destroyed. - */ - private lateinit var wakeLock: PowerManager.WakeLock - - private lateinit var notifier: LibraryUpdateNotifier - - private var job: Job? = null - - private val mangaToUpdate = mutableListOf() - - private val mangaToUpdateMap = mutableMapOf>() - - private val categoryIds = mutableSetOf() - - // List containing new updates - private val newUpdates = mutableMapOf>() - - // List containing failed updates - private val failedUpdates = mutableMapOf() - - // List containing skipped updates - private val skippedUpdates = mutableMapOf() - - val count = AtomicInteger(0) - val jobCount = AtomicInteger(0) - - // Boolean to determine if user wants to automatically download new chapters. - private val downloadNew: Boolean = preferences.downloadNewChapters().get() - - // Boolean to determine if DownloadManager has downloads - private var hasDownloads = false - - private val requestSemaphore = Semaphore(5) - - // For updates delete removed chapters if not preference is set as well - private val deleteRemoved by lazy { - preferences.deleteRemovedChapters().get() != 1 - } - - /** - * Defines what should be updated within a service execution. - */ - enum class Target { - - CHAPTERS, // Manga chapters - - DETAILS, // Manga metadata - - TRACKING, // Tracking metadata - } - - /** - * Method called when the service receives an intent. - * - * @param intent the start intent from. - * @param flags the flags of the command. - * @param startId the start id of this command. - * @return the start value of the command. - */ - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent == null) return START_NOT_STICKY - val target = intent.getSerializableExtra(KEY_TARGET) as? Target ?: return START_NOT_STICKY - - instance = this - - val savedMangasList = intent.getLongArrayExtra(KEY_MANGAS)?.asList() - - val mangaList = ( - if (savedMangasList != null) { - val mangas = db.getLibraryMangas().executeAsBlocking().filter { - it.id in savedMangasList - }.distinctBy { it.id } - val categoryId = intent.getIntExtra(KEY_CATEGORY, -1) - if (categoryId > -1) categoryIds.add(categoryId) - mangas - } else { - getMangaToUpdate(intent) - } - ).sortedBy { it.title } - // Update favorite manga. Destroy service when completed or in case of an error. - launchTarget(target, mangaList, startId) - return START_REDELIVER_INTENT - } - - /** - * Method called when the service is created. It injects dagger dependencies and acquire - * the wake lock. - */ - override fun onCreate() { - super.onCreate() - notifier = LibraryUpdateNotifier(this.localeContext) - wakeLock = acquireWakeLock(timeout = TimeUnit.MINUTES.toMillis(30)) - startForeground(Notifications.ID_LIBRARY_PROGRESS, notifier.progressNotificationBuilder.build()) - } - - /** - * Method called when the service is destroyed. It cancels jobs and releases the wake lock. - */ - override fun onDestroy() { - job?.cancel() - if (instance == this) { - instance = null - } - if (wakeLock.isHeld) { - wakeLock.release() - } - listener?.onUpdateManga() - super.onDestroy() - } - - private fun getMangaToUpdate(intent: Intent): List { - val categoryId = intent.getIntExtra(KEY_CATEGORY, -1) - return getMangaToUpdate(categoryId) - } - - /** - * Returns the list of manga to be updated. - * - * @param intent the update intent. - * @param target the target to update. - * @return a list of manga to update - */ - private fun getMangaToUpdate(categoryId: Int): List { - val libraryManga = db.getLibraryMangas().executeAsBlocking() - - val listToUpdate = if (categoryId != -1) { - categoryIds.add(categoryId) - libraryManga.filter { it.category == categoryId } - } else { - val categoriesToUpdate = - preferences.libraryUpdateCategories().get().map(String::toInt) - if (categoriesToUpdate.isNotEmpty()) { - categoryIds.addAll(categoriesToUpdate) - libraryManga.filter { it.category in categoriesToUpdate }.distinctBy { it.id } - } else { - categoryIds.addAll(db.getCategories().executeAsBlocking().mapNotNull { it.id } + 0) - libraryManga.distinctBy { it.id } - } - } - - val categoriesToExclude = - preferences.libraryUpdateCategoriesExclude().get().map(String::toInt) - val listToExclude = if (categoriesToExclude.isNotEmpty() && categoryId == -1) { - libraryManga.filter { it.category in categoriesToExclude }.toSet() - } else { - emptySet() - } - - return listToUpdate.minus(listToExclude) - } - - private fun launchTarget(target: Target, mangaToAdd: List, startId: Int) { - val handler = CoroutineExceptionHandler { _, exception -> - Timber.e(exception) - stopSelf(startId) - } - if (target == Target.CHAPTERS) { - listener?.onUpdateManga(Manga.create(STARTING_UPDATE_SOURCE)) - // If this is a chapter update, set the last update time to now - preferences.libraryUpdateLastTimestamp().set(Date().time) - } - job = GlobalScope.launch(handler) { - when (target) { - Target.CHAPTERS -> updateChaptersJob(filterMangaToUpdate(mangaToAdd)) - Target.DETAILS -> updateDetails(mangaToAdd) - else -> updateTrackings(mangaToAdd) - } - } - - job?.invokeOnCompletion { stopSelf(startId) } - } - - private fun addManga(mangaToAdd: List) { - val distinctManga = mangaToAdd.filter { it !in mangaToUpdate } - mangaToUpdate.addAll(distinctManga) - checkIfMassiveUpdate() - distinctManga.groupBy { it.source }.forEach { - // if added queue items is a new source not in the async list or an async list has - // finished running - if (mangaToUpdateMap[it.key].isNullOrEmpty()) { - mangaToUpdateMap[it.key] = it.value - jobCount.andIncrement - val handler = CoroutineExceptionHandler { _, exception -> - Timber.e(exception) - } - GlobalScope.launch(handler) { - val hasDLs = try { - requestSemaphore.withPermit { updateMangaInSource(it.key) } - } catch (e: Exception) { - false - } - hasDownloads = hasDownloads || hasDLs - jobCount.andDecrement - finishUpdates() - } - } else { - val list = mangaToUpdateMap[it.key] ?: emptyList() - mangaToUpdateMap[it.key] = (list + it.value) - } - } - } - - private fun addMangaToQueue(categoryId: Int, manga: List) { - val mangas = filterMangaToUpdate(manga).sortedBy { it.title } - categoryIds.add(categoryId) - addManga(mangas) - } - - private fun addCategory(categoryId: Int) { - val mangas = filterMangaToUpdate(getMangaToUpdate(categoryId)).sortedBy { it.title } - categoryIds.add(categoryId) - addManga(mangas) - } - - /** - * This method needs to be implemented, but it's not used/needed. - */ - override fun onBind(intent: Intent): IBinder? { - return null - } - - private fun filterMangaToUpdate(mangaToAdd: List): List { - val restrictions = preferences.libraryUpdateMangaRestriction().get() - return mangaToAdd.filter { manga -> - when { - MANGA_NON_COMPLETED in restrictions && manga.status == SManga.COMPLETED -> { - skippedUpdates[manga] = getString(R.string.skipped_reason_completed) - } - MANGA_HAS_UNREAD in restrictions && manga.unread != 0 -> { - skippedUpdates[manga] = getString(R.string.skipped_reason_not_caught_up) - } - MANGA_NON_READ in restrictions && manga.totalChapters > 0 && !manga.hasRead -> { - skippedUpdates[manga] = getString(R.string.skipped_reason_not_started) - } - manga.update_strategy != UpdateStrategy.ALWAYS_UPDATE -> { - skippedUpdates[manga] = getString(R.string.skipped_reason_not_always_update) - } - else -> { - return@filter true - } - } - return@filter false - } - } - - private fun checkIfMassiveUpdate() { - val largestSourceSize = mangaToUpdate - .groupBy { it.source } - .filterKeys { sourceManager.get(it) !is UnmeteredSource } - .maxOfOrNull { it.value.size } ?: 0 - if (largestSourceSize > MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD) { - notifier.showQueueSizeWarningNotification() - } - } - - private suspend fun updateChaptersJob(mangaToAdd: List) { - // Initialize the variables holding the progress of the updates. - - mangaToUpdate.addAll(mangaToAdd) - mangaToUpdateMap.putAll(mangaToAdd.groupBy { it.source }) - checkIfMassiveUpdate() - coroutineScope { - jobCount.andIncrement - val list = mangaToUpdateMap.keys.map { source -> - async { - try { - requestSemaphore.withPermit { - updateMangaInSource(source) - } - } catch (e: Exception) { - Timber.e(e) - false - } - } - } - val results = list.awaitAll() - hasDownloads = hasDownloads || results.any { it } - jobCount.andDecrement - finishUpdates() - } - } - - private suspend fun finishUpdates() { - if (jobCount.get() != 0) return - if (newUpdates.isNotEmpty()) { - notifier.showResultNotification(newUpdates) - - if (preferences.refreshCoversToo().get() && job?.isCancelled == false) { - updateDetails(newUpdates.keys.toList()) - notifier.cancelProgressNotification() - if (downloadNew && hasDownloads) { - DownloadService.start(this) - } - } else if (downloadNew && hasDownloads) { - DownloadService.start(this.applicationContext) - } - newUpdates.clear() - } - if (skippedUpdates.isNotEmpty() && Notifications.isNotificationChannelEnabled(this, Notifications.CHANNEL_LIBRARY_SKIPPED)) { - val skippedFile = writeErrorFile( - skippedUpdates, - "skipped", - getString(R.string.learn_why) + " - " + LibraryUpdateNotifier.HELP_SKIPPED_URL, - ).getUriCompat(this) - notifier.showUpdateSkippedNotification(skippedUpdates.map { it.key.title }, skippedFile) - } - if (failedUpdates.isNotEmpty() && Notifications.isNotificationChannelEnabled(this, Notifications.CHANNEL_LIBRARY_ERROR)) { - val errorFile = writeErrorFile(failedUpdates).getUriCompat(this) - notifier.showUpdateErrorNotification(failedUpdates.map { it.key.title }, errorFile) - } - mangaShortcutManager.updateShortcuts(this) - failedUpdates.clear() - notifier.cancelProgressNotification() - if (runExtensionUpdatesAfter && !DownloadService.isRunning(this)) { - ExtensionUpdateJob.runJobAgain(this, NetworkType.CONNECTED) - runExtensionUpdatesAfter = false - } - } - - private suspend fun updateMangaInSource(source: Long): Boolean { - if (mangaToUpdateMap[source] == null) return false - var count = 0 - var hasDownloads = false - while (count < mangaToUpdateMap[source]!!.size) { - val manga = mangaToUpdateMap[source]!![count] - val shouldDownload = manga.shouldDownloadNewChapters(db, preferences) - if (updateMangaChapters(manga, this.count.andIncrement, shouldDownload)) { - hasDownloads = true - } - count++ - } - mangaToUpdateMap[source] = emptyList() - return hasDownloads - } - - private suspend fun updateMangaChapters( - manga: LibraryManga, - progress: Int, - shouldDownload: Boolean, - ): Boolean { - try { - var hasDownloads = false - if (job?.isCancelled == true) { - return false - } - notifier.showProgressNotification(manga, progress, mangaToUpdate.size) - val source = sourceManager.get(manga.source) as? HttpSource ?: return false - val fetchedChapters = withContext(Dispatchers.IO) { - source.getChapterList(manga) - } - if (fetchedChapters.isNotEmpty()) { - val newChapters = syncChaptersWithSource(db, fetchedChapters, manga, source) - if (newChapters.first.isNotEmpty()) { - if (shouldDownload) { - downloadChapters(manga, newChapters.first.sortedBy { it.chapter_number }) - hasDownloads = true - } - newUpdates[manga] = - newChapters.first.sortedBy { it.chapter_number }.toTypedArray() - } - if (deleteRemoved && newChapters.second.isNotEmpty()) { - val removedChapters = newChapters.second.filter { - downloadManager.isChapterDownloaded(it, manga) && - newChapters.first.none { newChapter -> - newChapter.chapter_number == it.chapter_number && it.scanlator.isNullOrBlank() - } - } - if (removedChapters.isNotEmpty()) { - downloadManager.deleteChapters(removedChapters, manga, source) - } - } - if (newChapters.first.size + newChapters.second.size > 0) { - listener?.onUpdateManga( - manga, - ) - } - } - return hasDownloads - } catch (e: Exception) { - if (e !is CancellationException) { - failedUpdates[manga] = e.message - Timber.e("Failed updating: ${manga.title}: $e") - } - return false - } - } - - private fun downloadChapters(manga: Manga, chapters: List) { - // We don't want to start downloading while the library is updating, because websites - // may don't like it and they could ban the user. - downloadManager.downloadChapters(manga, chapters, false) - } - - /** - * Method that updates the details of the given list of manga. It's called in a background - * thread, so it's safe to do heavy operations or network calls here. - * - * @param mangaToUpdate the list to update - */ - suspend fun updateDetails(mangaToUpdate: List) = coroutineScope { - // Initialize the variables holding the progress of the updates. - val count = AtomicInteger(0) - val asyncList = mangaToUpdate.groupBy { it.source }.values.map { list -> - async { - requestSemaphore.withPermit { - list.forEach { manga -> - if (job?.isCancelled == true) { - return@async - } - val source = sourceManager.get(manga.source) as? HttpSource ?: return@async - notifier.showProgressNotification( - manga, - count.andIncrement, - mangaToUpdate.size, - ) - - val networkManga = try { - source.getMangaDetails(manga.copy()) - } catch (e: java.lang.Exception) { - Timber.e(e) - null - } - if (networkManga != null) { - val thumbnailUrl = manga.thumbnail_url - manga.copyFrom(networkManga) - manga.initialized = true - if (thumbnailUrl != manga.thumbnail_url) { - coverCache.deleteFromCache(thumbnailUrl) - // load new covers in background - val request = - ImageRequest.Builder(this@LibraryUpdateService).data(manga) - .memoryCachePolicy(CachePolicy.DISABLED).build() - Coil.imageLoader(this@LibraryUpdateService).execute(request) - } else { - val request = - ImageRequest.Builder(this@LibraryUpdateService).data(manga) - .memoryCachePolicy(CachePolicy.DISABLED) - .diskCachePolicy(CachePolicy.WRITE_ONLY) - .build() - Coil.imageLoader(this@LibraryUpdateService).execute(request) - } - db.insertManga(manga).executeAsBlocking() - } - } - } - } - } - asyncList.awaitAll() - notifier.cancelProgressNotification() - } - - /** - * Method that updates the metadata of the connected tracking services. It's called in a - * background thread, so it's safe to do heavy operations or network calls here. - */ - - private suspend fun updateTrackings(mangaToUpdate: List) { - // Initialize the variables holding the progress of the updates. - var count = 0 - - val loggedServices = trackManager.services.filter { it.isLogged } - - mangaToUpdate.forEach { manga -> - notifier.showProgressNotification(manga, count++, mangaToUpdate.size) - - val tracks = db.getTracks(manga).executeAsBlocking() - - tracks.forEach { track -> - val service = trackManager.getService(track.sync_id) - if (service != null && service in loggedServices) { - try { - val newTrack = service.refresh(track) - db.insertTrack(newTrack).executeAsBlocking() - - if (service is EnhancedTrackService) { - syncChaptersWithTrackServiceTwoWay(db, db.getChapters(manga).executeAsBlocking(), track, service) - } - } catch (e: Exception) { - Timber.e(e) - } - } - } - } - notifier.cancelProgressNotification() - } - - /** - * Writes basic file of update errors to cache dir. - */ - private fun writeErrorFile(errors: Map, fileName: String = "errors", additionalInfo: String? = null): File { - try { - if (errors.isNotEmpty()) { - val file = createFileInCacheDir("tachiyomi_update_$fileName.txt") - file.bufferedWriter().use { out -> - additionalInfo?.let { out.write("$it\n\n") } - // Error file format: - // ! Error - // # Source - // - Manga - errors.toList().groupBy({ it.second }, { it.first }).forEach { (error, mangas) -> - out.write("! ${error}\n") - mangas.groupBy { it.source }.forEach { (srcId, mangas) -> - val source = sourceManager.getOrStub(srcId) - out.write(" # $source\n") - mangas.forEach { - out.write(" - ${it.title}\n") - } - } - } - } - return file - } - } catch (e: Exception) { - // Empty - } - return File("") - } - - companion object { - - /** - * Key for category to update. - */ - const val KEY_CATEGORY = "category" - const val STARTING_UPDATE_SOURCE = -5L - - fun categoryInQueue(id: Int?) = instance?.categoryIds?.contains(id) ?: false - private var instance: LibraryUpdateService? = null - - /** - * Key that defines what should be updated. - */ - const val KEY_TARGET = "target" - - /** - * Key for list of manga to be updated. (For dynamic categories) - */ - const val KEY_MANGAS = "mangas" - - var runExtensionUpdatesAfter = false - - /** - * Returns the status of the service. - * - * @return true if the service is running, false otherwise. - */ - fun isRunning() = instance != null - - /** - * Starts the service. It will be started only if there isn't another instance already - * running. - * - * @param context the application context. - * @param category a specific category to update, or null for global update. - * @param target defines what should be updated. - */ - fun start( - context: Context, - category: Category? = null, - target: Target = Target.CHAPTERS, - mangaToUse: List? = null, - ): Boolean { - return if (!isRunning()) { - val intent = Intent(context, LibraryUpdateService::class.java).apply { - putExtra(KEY_TARGET, target) - category?.id?.let { id -> - putExtra(KEY_CATEGORY, id) - if (mangaToUse != null) { - putExtra( - KEY_MANGAS, - mangaToUse.mapNotNull { it.id }.toLongArray(), - ) - } - } - } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - context.startService(intent) - } else { - context.startForegroundService(intent) - } - true - } else { - if (target == Target.CHAPTERS) { - category?.id?.let { - if (mangaToUse != null) { - instance?.addMangaToQueue(it, mangaToUse) - } else { - instance?.addCategory(it) - } - } - } - false - } - } - - /** - * Stops the service. - * - * @param context the application context. - */ - fun stop(context: Context) { - instance?.job?.cancel() - GlobalScope.launch { - instance?.jobCount?.set(0) - instance?.finishUpdates() - } - context.stopService(Intent(context, LibraryUpdateService::class.java)) - } - - private var listener: LibraryServiceListener? = null - - fun setListener(listener: LibraryServiceListener) { - this.listener = listener - } - - fun removeListener(listener: LibraryServiceListener) { - if (this.listener == listener) this.listener = null - } - - fun callListener(manga: Manga) { - listener?.onUpdateManga(manga) - } - } -} - -interface LibraryServiceListener { - fun onUpdateManga(manga: Manga? = null) -} - -const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60 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 9dc2980d84..2ff21f443b 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 @@ -6,17 +6,19 @@ import android.content.ClipData import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Handler -import eu.kanade.tachiyomi.data.backup.BackupRestoreService +import androidx.core.content.IntentCompat +import eu.kanade.tachiyomi.data.backup.BackupRestoreJob import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.download.DownloadJob import eu.kanade.tachiyomi.data.download.DownloadManager -import eu.kanade.tachiyomi.data.download.DownloadService -import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.updater.AppUpdateService -import eu.kanade.tachiyomi.extension.ExtensionInstallService +import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob +import eu.kanade.tachiyomi.extension.ExtensionInstallerJob +import eu.kanade.tachiyomi.extension.ExtensionManager +import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.manga.MangaDetailsController @@ -30,6 +32,7 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy import java.io.File +import java.util.ArrayList import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID /** @@ -48,10 +51,9 @@ class NotificationReceiver : BroadcastReceiver() { // Dismiss notification ACTION_DISMISS_NOTIFICATION -> dismissNotification(context, intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)) // Resume the download service - ACTION_RESUME_DOWNLOADS -> DownloadService.start(context) + ACTION_RESUME_DOWNLOADS -> DownloadJob.start(context) // Pause the download service ACTION_PAUSE_DOWNLOADS -> { - DownloadService.stop(context) downloadManager.pauseDownloads() } // Clear the download queue @@ -65,7 +67,9 @@ class NotificationReceiver : BroadcastReceiver() { // Cancel library update and dismiss notification ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) ACTION_CANCEL_EXTENSION_UPDATE -> cancelExtensionUpdate(context) + ACTION_START_EXTENSION_INSTALL -> startExtensionUpdater(context, intent) ACTION_CANCEL_UPDATE_DOWNLOAD -> cancelDownloadUpdate(context) + ACTION_START_APP_UPDATE -> startAppUpdate(context, intent) ACTION_CANCEL_RESTORE -> cancelRestoreUpdate(context) // Share backup file ACTION_SHARE_BACKUP -> @@ -171,19 +175,26 @@ class NotificationReceiver : BroadcastReceiver() { * @param notificationId id of notification */ private fun cancelLibraryUpdate(context: Context) { - LibraryUpdateService.stop(context) - Handler().post { dismissNotification(context, Notifications.ID_LIBRARY_PROGRESS) } + LibraryUpdateJob.stop(context) } /** * Method called when user wants to stop a library update * * @param context context of application - * @param notificationId id of notification */ private fun cancelExtensionUpdate(context: Context) { - ExtensionInstallService.stop(context) - Handler().post { dismissNotification(context, Notifications.ID_EXTENSION_PROGRESS) } + dismissNotification(context, Notifications.ID_EXTENSION_PROGRESS) + ExtensionInstallerJob.stop(context) + } + + private fun startExtensionUpdater(context: Context, intent: Intent) { + val extensions = IntentCompat.getParcelableArrayListExtra( + intent, + ExtensionInstallerJob.KEY_EXTENSION, + ExtensionManager.ExtensionInfo::class.java, + ) as? ArrayList ?: return + ExtensionInstallerJob.startJob(context, extensions, 1) } /** @@ -195,12 +206,12 @@ class NotificationReceiver : BroadcastReceiver() { private fun markAsRead(chapterUrls: Array, mangaId: Long) { val db: DatabaseHelper = Injekt.get() val preferences: PreferencesHelper = Injekt.get() + val manga = db.getManga(mangaId).executeAsBlocking() ?: return val chapters = chapterUrls.map { val chapter = db.getChapter(it, mangaId).executeAsBlocking() ?: return chapter.read = true db.updateChapterProgress(chapter).executeAsBlocking() if (preferences.removeAfterMarkedAsRead()) { - val manga = db.getManga(mangaId).executeAsBlocking() ?: return val sourceManager: SourceManager = Injekt.get() val source = sourceManager.get(manga.source) ?: return downloadManager.deleteChapters(listOf(chapter), manga, source) @@ -208,6 +219,7 @@ class NotificationReceiver : BroadcastReceiver() { return@map chapter } val newLastChapter = chapters.maxByOrNull { it.chapter_number.toInt() } + LibraryUpdateJob.updateMutableFlow.tryEmit(manga.id) updateTrackChapterMarkedAsRead(db, preferences, newLastChapter, mangaId, 0) } @@ -216,12 +228,19 @@ class NotificationReceiver : BroadcastReceiver() { * @param context context of application */ private fun cancelRestoreUpdate(context: Context) { - BackupRestoreService.stop(context) - Handler().post { dismissNotification(context, Notifications.ID_RESTORE_PROGRESS) } + BackupRestoreJob.stop(context) } private fun cancelDownloadUpdate(context: Context) { - AppUpdateService.stop(context) + AppDownloadInstallJob.stop(context) + dismissNotification(context, Notifications.ID_UPDATER) + } + + private fun startAppUpdate(context: Context, intent: Intent) { + val url = intent.getStringExtra(AppDownloadInstallJob.EXTRA_DOWNLOAD_URL) ?: return + val notifyOnInstall = + intent.getBooleanExtra(AppDownloadInstallJob.EXTRA_NOTIFY_ON_INSTALL, false) + AppDownloadInstallJob.start(context, url, notifyOnInstall) } companion object { @@ -241,8 +260,12 @@ class NotificationReceiver : BroadcastReceiver() { // Called to cancel extension update. private const val ACTION_CANCEL_EXTENSION_UPDATE = "$ID.$NAME.CANCEL_EXTENSION_UPDATE" + private const val ACTION_START_EXTENSION_INSTALL = "$ID.$NAME.START_EXTENSION_INSTALL" + private const val ACTION_CANCEL_UPDATE_DOWNLOAD = "$ID.$NAME.CANCEL_UPDATE_DOWNLOAD" + private const val ACTION_START_APP_UPDATE = "$ID.$NAME.START_APP_UPDATE" + // Called to mark as read private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ" @@ -550,6 +573,34 @@ class NotificationReceiver : BroadcastReceiver() { return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } + internal fun startExtensionUpdatePendingJob(context: Context, extensions: List): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + val info = extensions.map(ExtensionManager::ExtensionInfo) + action = ACTION_START_EXTENSION_INSTALL + putParcelableArrayListExtra(ExtensionInstallerJob.KEY_EXTENSION, ArrayList(info)) + } + return PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + internal fun startAppUpdatePendingJob(context: Context, url: String, notifyOnInstall: Boolean = false): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_START_APP_UPDATE + putExtra(AppDownloadInstallJob.EXTRA_DOWNLOAD_URL, url) + putExtra(AppDownloadInstallJob.EXTRA_NOTIFY_ON_INSTALL, notifyOnInstall) + } + return PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + /** * Returns [PendingIntent] that cancels the download for a Tachiyomi update * diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 369e25922b..97a56c31ad 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -12,7 +12,7 @@ import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.track.TrackService -import eu.kanade.tachiyomi.data.updater.AutoAppUpdaterJob +import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob import eu.kanade.tachiyomi.extension.model.InstalledExtensionsOrder import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.ui.library.LibraryItem @@ -96,6 +96,8 @@ class PreferencesHelper(val context: Context) { fun startingTab() = flowPrefs.getInt(Keys.startingTab, 0) fun backReturnsToStart() = flowPrefs.getBoolean(Keys.backToStart, true) + fun hasShownNotifPermission() = flowPrefs.getBoolean("has_shown_notification_permission", false) + fun hasDeniedA11FilePermission() = flowPrefs.getBoolean(Keys.deniedA11FilePermission, false) fun clear() = prefs.edit().clear().apply() @@ -461,9 +463,9 @@ class PreferencesHelper(val context: Context) { fun sideNavMode() = flowPrefs.getInt(Keys.sideNavMode, 0) - fun appShouldAutoUpdate() = prefs.getInt(Keys.shouldAutoUpdate, AutoAppUpdaterJob.ONLY_ON_UNMETERED) + fun appShouldAutoUpdate() = prefs.getInt(Keys.shouldAutoUpdate, AppDownloadInstallJob.ONLY_ON_UNMETERED) - fun autoUpdateExtensions() = prefs.getInt(Keys.autoUpdateExtensions, AutoAppUpdaterJob.ONLY_ON_UNMETERED) + fun autoUpdateExtensions() = prefs.getInt(Keys.autoUpdateExtensions, AppDownloadInstallJob.ONLY_ON_UNMETERED) fun useShizukuForExtensions() = prefs.getBoolean(Keys.useShizuku, false) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppDownloadInstallJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppDownloadInstallJob.kt new file mode 100644 index 0000000000..305d47e737 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppDownloadInstallJob.kt @@ -0,0 +1,293 @@ +package eu.kanade.tachiyomi.data.updater + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +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.connectivityManager +import eu.kanade.tachiyomi.util.system.jobIsRunning +import eu.kanade.tachiyomi.util.system.launchUI +import eu.kanade.tachiyomi.util.system.localeContext +import eu.kanade.tachiyomi.util.system.notificationManager +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.system.tryToSetForeground +import eu.kanade.tachiyomi.util.system.withIOContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.internal.http2.ErrorCode +import okhttp3.internal.http2.StreamResetException +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import java.io.File +import java.lang.ref.WeakReference + +@OptIn(DelicateCoroutinesApi::class) +class AppDownloadInstallJob(private val context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + + private val notifier = AppUpdateNotifier(context.localeContext) + private val network: NetworkHelper by injectLazy() + private var runningCall: Call? = null + val preferences = Injekt.get() + + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = notifier.onDownloadStarted().build() + val id = Notifications.ID_UPDATER + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(id, notification) + } + } + override suspend fun doWork(): Result { + val idleRun = inputData.getBoolean(IDLE_RUN, false) + val url: String + if (idleRun) { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !context.packageManager.canRequestPackageInstalls() + ) { + return Result.failure() + } + if (preferences.appShouldAutoUpdate() == ONLY_ON_UNMETERED && + context.connectivityManager.isActiveNetworkMetered + ) { + return Result.retry() + } + + val result = withIOContext { + AppUpdateChecker().checkForUpdate(context, true, doExtrasAfterNewUpdate = false) + } + if (result is AppUpdateResult.NewUpdate) { + AppUpdateNotifier(context.localeContext).cancel() + AppUpdateNotifier.releasePageUrl = result.release.releaseLink + url = result.release.downloadLink + } else { + return Result.success() + } + } else { + url = inputData.getString(EXTRA_DOWNLOAD_URL) ?: return Result.failure() + } + + tryToSetForeground() + instance = WeakReference(this) + + val notifyOnInstall = inputData.getBoolean(EXTRA_NOTIFY_ON_INSTALL, false) + + withIOContext { + downloadApk(url, notifyOnInstall) + } + + runningCall?.cancel() + instance = null + return Result.success() + } + + /** + * Called to start downloading apk of new update + * + * @param url url location of file + */ + private suspend fun downloadApk(url: String, notifyOnInstall: Boolean) = coroutineScope { + 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 call = network.client.newCachelessCallWithProgress(GET(url), progressListener) + runningCall = call + val response = call.await() + if (isStopped) { + cancel() + return@coroutineScope + } + + // 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") + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + startInstalling(apkFile, notifyOnInstall) + } else { + notifier.onDownloadFinished(apkFile.getUriCompat(context)) + } + } catch (error: Exception) { + Timber.e(error) + if (error is CancellationException || isStopped || + (error is StreamResetException && error.errorCode == ErrorCode.CANCEL) + ) { + notifier.cancel() + } else { + notifier.onDownloadError(url) + } + } + } + + @RequiresApi(31) + private suspend fun startInstalling(file: File, notifyOnInstall: Boolean) { + try { + val packageInstaller = context.packageManager.packageInstaller + val data = file.inputStream() + + val params = PackageInstaller.SessionParams( + PackageInstaller.SessionParams.MODE_FULL_INSTALL, + ) + params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) + val sessionId = packageInstaller.createSession(params) + val session = packageInstaller.openSession(sessionId) + session.openWrite("package", 0, -1).use { packageInSession -> + data.copyTo(packageInSession) + } + if (notifyOnInstall) { + PreferenceManager.getDefaultSharedPreferences(context).edit { + putBoolean(NOTIFY_ON_INSTALL_KEY, true) + } + } + + val newIntent = Intent(context, AppUpdateBroadcast::class.java) + .setAction(PACKAGE_INSTALLED_ACTION) + .putExtra(EXTRA_NOTIFY_ON_INSTALL, notifyOnInstall) + .putExtra(EXTRA_FILE_URI, file.getUriCompat(context).toString()) + + val pendingIntent = PendingIntent.getBroadcast(context, -10053, newIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE) + val statusReceiver = pendingIntent.intentSender + session.commit(statusReceiver) + notifier.onInstalling() + withContext(Dispatchers.IO) { + data.close() + GlobalScope.launchUI { + delay(5000) + val hasNotification = context.notificationManager + .activeNotifications.any { it.id == Notifications.ID_UPDATER } + // If the package manager crashes for whatever reason (china phone) + // set a timeout and let the user manually install + if (packageInstaller.getSessionInfo(sessionId) == null && !hasNotification) { + notifier.cancelInstallNotification() + notifier.onDownloadFinished(file.getUriCompat(context)) + PreferenceManager.getDefaultSharedPreferences(context).edit { + remove(NOTIFY_ON_INSTALL_KEY) + } + } + } + } + } catch (error: Exception) { + // Either install package can't be found (probably bots) or there's a security exception + // with the download manager. Nothing we can workaround. + context.toast(error.message) + notifier.cancelInstallNotification() + notifier.onDownloadFinished(file.getUriCompat(context)) + PreferenceManager.getDefaultSharedPreferences(context).edit { + remove(NOTIFY_ON_INSTALL_KEY) + } + } + } + + companion object { + private const val TAG = "AppDownloadInstaller" + const val PACKAGE_INSTALLED_ACTION = + "${BuildConfig.APPLICATION_ID}.SESSION_SELF_API_PACKAGE_INSTALLED" + internal const val EXTRA_FILE_URI = "${BuildConfig.APPLICATION_ID}.AppInstaller.FILE_URI" + internal const val EXTRA_NOTIFY_ON_INSTALL = "ACTION_ON_INSTALL" + internal const val EXTRA_DOWNLOAD_URL = "DOWNLOAD_URL" + internal const val NOTIFY_ON_INSTALL_KEY = "notify_on_install_complete" + private const val IDLE_RUN = "idle_run" + + const val ALWAYS = 0 + const val ONLY_ON_UNMETERED = 1 + const val NEVER = 2 + + private var instance: WeakReference? = null + + fun start(context: Context, url: String?, notifyOnInstall: Boolean, waitUntilIdle: Boolean = false) { + val data = Data.Builder() + data.putString(EXTRA_DOWNLOAD_URL, url) + data.putBoolean(EXTRA_NOTIFY_ON_INSTALL, notifyOnInstall) + val request = OneTimeWorkRequestBuilder() + .addTag(TAG) + .apply { + if (waitUntilIdle) { + data.putBoolean(IDLE_RUN, true) + val shouldAutoUpdate = Injekt.get().appShouldAutoUpdate() + val constraints = Constraints.Builder() + .setRequiredNetworkType( + if (shouldAutoUpdate == ALWAYS) { + NetworkType.CONNECTED + } else { + NetworkType.UNMETERED + }, + ) + .setRequiresDeviceIdle(true) + .build() + setConstraints(constraints) + } else { + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + } + setInputData(data.build()) + } + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request) + } + + fun stop(context: Context) { + instance?.get()?.runningCall?.cancel() + WorkManager.getInstance(context).cancelUniqueWork(TAG) + } + + fun isRunning(context: Context) = WorkManager.getInstance(context).jobIsRunning(TAG) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateBroadcast.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateBroadcast.kt index 236e4711bc..50fcc0f88f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateBroadcast.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateBroadcast.kt @@ -13,7 +13,7 @@ import eu.kanade.tachiyomi.util.system.toast class AppUpdateBroadcast : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - if (AppUpdateService.PACKAGE_INSTALLED_ACTION == intent.action) { + if (AppDownloadInstallJob.PACKAGE_INSTALLED_ACTION == intent.action) { val extras = intent.extras ?: return when (val status = extras.getInt(PackageInstaller.EXTRA_STATUS)) { PackageInstaller.STATUS_PENDING_USER_ACTION -> { @@ -23,21 +23,21 @@ class AppUpdateBroadcast : BroadcastReceiver() { PackageInstaller.STATUS_SUCCESS -> { val prefs = PreferenceManager.getDefaultSharedPreferences(context) prefs.edit { - remove(AppUpdateService.NOTIFY_ON_INSTALL_KEY) + remove(AppDownloadInstallJob.NOTIFY_ON_INSTALL_KEY) } - val notifyOnInstall = extras.getBoolean(AppUpdateService.EXTRA_NOTIFY_ON_INSTALL, false) + val notifyOnInstall = extras.getBoolean(AppDownloadInstallJob.EXTRA_NOTIFY_ON_INSTALL, false) try { if (notifyOnInstall) { AppUpdateNotifier(context.localeContext).onInstallFinished() } } finally { - AppUpdateService.stop(context) + AppDownloadInstallJob.stop(context) } } PackageInstaller.STATUS_FAILURE, PackageInstaller.STATUS_FAILURE_ABORTED, PackageInstaller.STATUS_FAILURE_BLOCKED, PackageInstaller.STATUS_FAILURE_CONFLICT, PackageInstaller.STATUS_FAILURE_INCOMPATIBLE, PackageInstaller.STATUS_FAILURE_INVALID, PackageInstaller.STATUS_FAILURE_STORAGE -> { if (status != PackageInstaller.STATUS_FAILURE_ABORTED) { context.toast(R.string.could_not_install_update) - val uri = intent.getStringExtra(AppUpdateService.EXTRA_FILE_URI) ?: return + val uri = intent.getStringExtra(AppDownloadInstallJob.EXTRA_FILE_URI) ?: return val appUpdateNotifier = AppUpdateNotifier(context.localeContext) appUpdateNotifier.cancelInstallNotification() appUpdateNotifier.onInstallError(uri.toUri()) @@ -46,9 +46,9 @@ class AppUpdateBroadcast : BroadcastReceiver() { } } else if (intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) { val prefs = PreferenceManager.getDefaultSharedPreferences(context) - val notifyOnInstall = prefs.getBoolean(AppUpdateService.NOTIFY_ON_INSTALL_KEY, false) + val notifyOnInstall = prefs.getBoolean(AppDownloadInstallJob.NOTIFY_ON_INSTALL_KEY, false) prefs.edit { - remove(AppUpdateService.NOTIFY_ON_INSTALL_KEY) + remove(AppDownloadInstallJob.NOTIFY_ON_INSTALL_KEY) } if (notifyOnInstall) { AppUpdateNotifier(context.localeContext).onInstallFinished() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt index 2c6d77c394..d820a7d8c8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt @@ -68,9 +68,9 @@ class AppUpdateChecker { } if (doExtrasAfterNewUpdate && result is AppUpdateResult.NewUpdate) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - preferences.appShouldAutoUpdate() != AutoAppUpdaterJob.NEVER + preferences.appShouldAutoUpdate() != AppDownloadInstallJob.NEVER ) { - AutoAppUpdaterJob.setupTask(context) + AppDownloadInstallJob.start(context, null, false, waitUntilIdle = true) } AppUpdateNotifier(context.localeContext).promptUpdate(result.release) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateJob.kt index 450a75c352..a8ec427365 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateJob.kt @@ -51,7 +51,7 @@ class AppUpdateJob(private val context: Context, workerParams: WorkerParameters) .setConstraints(constraints) .build() - WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request) + WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request) } fun cancelTask(context: Context) { 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 f731ca12cb..49c2ce2c98 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 @@ -26,8 +26,11 @@ internal class AppUpdateNotifier(private val context: Context) { /** * Builder to manage notifications. */ - private val notificationBuilder by lazy { - NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON) + val notificationBuilder by lazy { + NotificationCompat.Builder(context, Notifications.CHANNEL_COMMON).apply { + setSmallIcon(android.R.drawable.stat_sys_download) + setContentTitle(context.getString(R.string.app_name)) + } } companion object { @@ -49,11 +52,6 @@ internal class AppUpdateNotifier(private val context: Context) { val releaseUrl = release.releaseLink val isBeta = release.preRelease == true - val intent = Intent(context, AppUpdateService::class.java).apply { - putExtra(AppUpdateService.EXTRA_DOWNLOAD_URL, url) - putExtra(AppUpdateService.EXTRA_NOTIFY_ON_INSTALL, true) - } - val pendingIntent = NotificationReceiver.openUpdatePendingActivity(context, body, url) releasePageUrl = releaseUrl with(notificationBuilder) { @@ -77,12 +75,7 @@ internal class AppUpdateNotifier(private val context: Context) { addAction( android.R.drawable.stat_sys_download_done, context.getString(if (isOnA12) R.string.update else R.string.download), - PendingIntent.getService( - context, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ), + NotificationReceiver.startAppUpdatePendingJob(context, url, true), ) addReleasePageAction() } @@ -107,16 +100,15 @@ internal class AppUpdateNotifier(private val context: Context) { * * @param title tile of notification. */ - fun onDownloadStarted(title: String): NotificationCompat.Builder { + fun onDownloadStarted(): NotificationCompat.Builder { with(notificationBuilder) { - setContentTitle(title) + setContentTitle(context.getString(R.string.app_name)) setContentText(context.getString(R.string.downloading)) setSmallIcon(android.R.drawable.stat_sys_download) + setProgress(0, 0, true) setAutoCancel(false) setOngoing(true) clearActions() - - // Cancel action addAction( R.drawable.ic_close_24dp, context.getString(R.string.cancel), @@ -135,8 +127,17 @@ internal class AppUpdateNotifier(private val context: Context) { */ fun onProgressChange(progress: Int) { with(notificationBuilder) { + setContentTitle(context.getString(R.string.app_name)) + setContentText(context.getString(R.string.downloading)) + setSmallIcon(android.R.drawable.stat_sys_download) setProgress(100, progress, false) setOnlyAlertOnce(true) + clearActions() + addAction( + R.drawable.ic_close_24dp, + context.getString(R.string.cancel), + NotificationReceiver.cancelUpdateDownloadPendingBroadcast(context), + ) } notificationBuilder.show() } @@ -183,11 +184,7 @@ internal class AppUpdateNotifier(private val context: Context) { notificationBuilder.show(Notifications.ID_INSTALL) } - /** - * Call when apk download is finished. - * - * @param uri path location of apk. - */ + /** Call when apk download is finished. */ fun onInstallFinished() { with(NotificationCompat.Builder(context, Notifications.CHANNEL_UPDATED)) { setContentTitle(context.getString(R.string.update_completed)) @@ -232,7 +229,7 @@ internal class AppUpdateNotifier(private val context: Context) { addAction( R.drawable.ic_refresh_24dp, context.getString(R.string.retry), - AppUpdateService.downloadApkPendingService(context, url), + NotificationReceiver.startAppUpdatePendingJob(context, url), ) // Cancel action addAction( 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 db8eb349b6..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateService.kt +++ /dev/null @@ -1,297 +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.content.pm.PackageInstaller -import android.os.Build -import android.os.IBinder -import android.os.PowerManager -import androidx.annotation.RequiresApi -import androidx.core.content.edit -import androidx.preference.PreferenceManager -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.launchNow -import eu.kanade.tachiyomi.util.system.localeContext -import eu.kanade.tachiyomi.util.system.notificationManager -import eu.kanade.tachiyomi.util.system.toast -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import okhttp3.Call -import okhttp3.internal.http2.ErrorCode -import okhttp3.internal.http2.StreamResetException -import timber.log.Timber -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 var runningJob: Job? = null - - private var runningCall: Call? = null - - override fun onCreate() { - super.onCreate() - notifier = AppUpdateNotifier(this.localeContext) - - startForeground(Notifications.ID_UPDATER, notifier.onDownloadStarted(getString(R.string.app_name)).build()) - - wakeLock = acquireWakeLock(javaClass.name) - } - - /** - * 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 - - instance = this - - val handler = CoroutineExceptionHandler { _, exception -> - Timber.e(exception) - stopSelf(startId) - } - - val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return START_NOT_STICKY - val title = intent.getStringExtra(EXTRA_DOWNLOAD_TITLE) ?: getString(R.string.app_name) - val notifyOnInstall = intent.getBooleanExtra(EXTRA_NOTIFY_ON_INSTALL, false) - - runningJob = GlobalScope.launch(handler) { - downloadApk(title, url, notifyOnInstall) - } - - runningJob?.invokeOnCompletion { stopSelf(startId) } - - return START_NOT_STICKY - } - - override fun stopService(name: Intent?): Boolean { - destroyJob() - return super.stopService(name) - } - - override fun onDestroy() { - destroyJob() - if (instance == this) { - instance = null - } - super.onDestroy() - } - - private fun destroyJob() { - runningJob?.cancel() - runningCall?.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, notifyOnInstall: Boolean) { - // 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 call = network.client.newCachelessCallWithProgress(GET(url), progressListener) - runningCall = call - val response = call.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") - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - startInstalling(apkFile, notifyOnInstall) - } else { - notifier.onDownloadFinished(apkFile.getUriCompat(this)) - } - } catch (error: Exception) { - Timber.e(error) - if (error is CancellationException || - (error is StreamResetException && error.errorCode == ErrorCode.CANCEL) - ) { - notifier.cancel() - } else { - notifier.onDownloadError(url) - } - } - } - - @RequiresApi(31) - private fun startInstalling(file: File, notifyOnInstall: Boolean) { - try { - val packageInstaller = packageManager.packageInstaller - val data = file.inputStream() - - val params = PackageInstaller.SessionParams( - PackageInstaller.SessionParams.MODE_FULL_INSTALL, - ) - params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) - val sessionId = packageInstaller.createSession(params) - val session = packageInstaller.openSession(sessionId) - session.openWrite("package", 0, -1).use { packageInSession -> - data.copyTo(packageInSession) - } - if (notifyOnInstall) { - PreferenceManager.getDefaultSharedPreferences(this).edit { - putBoolean(NOTIFY_ON_INSTALL_KEY, true) - } - } - - val newIntent = Intent(this, AppUpdateBroadcast::class.java) - .setAction(PACKAGE_INSTALLED_ACTION) - .putExtra(EXTRA_NOTIFY_ON_INSTALL, notifyOnInstall) - .putExtra(EXTRA_FILE_URI, file.getUriCompat(this).toString()) - - val pendingIntent = PendingIntent.getBroadcast(this, -10053, newIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE) - val statusReceiver = pendingIntent.intentSender - session.commit(statusReceiver) - notifier.onInstalling() - data.close() - - val hasNotification by lazy { - notificationManager.activeNotifications.any { it.id == Notifications.ID_UPDATER } - } - launchNow { - delay(5000) - // If the package manager crashes for whatever reason (china phone) set a timeout - // and let the user manually install - if (packageInstaller.getSessionInfo(sessionId) == null && !hasNotification) { - notifier.cancelInstallNotification() - notifier.onDownloadFinished(file.getUriCompat(this@AppUpdateService)) - PreferenceManager.getDefaultSharedPreferences(this@AppUpdateService).edit { - remove(NOTIFY_ON_INSTALL_KEY) - } - } - } - } catch (error: Exception) { - // Either install package can't be found (probably bots) or there's a security exception - // with the download manager. Nothing we can workaround. - toast(error.message) - notifier.cancelInstallNotification() - notifier.onDownloadFinished(file.getUriCompat(this)) - PreferenceManager.getDefaultSharedPreferences(this).edit { - remove(NOTIFY_ON_INSTALL_KEY) - } - } - } - - companion object { - - const val PACKAGE_INSTALLED_ACTION = - "${BuildConfig.APPLICATION_ID}.SESSION_SELF_API_PACKAGE_INSTALLED" - internal const val EXTRA_NOTIFY_ON_INSTALL = "${BuildConfig.APPLICATION_ID}.UpdaterService.ACTION_ON_INSTALL" - internal const val EXTRA_DOWNLOAD_URL = "${BuildConfig.APPLICATION_ID}.UpdaterService.DOWNLOAD_URL" - internal const val EXTRA_FILE_URI = "${BuildConfig.APPLICATION_ID}.UpdaterService.FILE_URI" - internal const val EXTRA_DOWNLOAD_TITLE = "${BuildConfig.APPLICATION_ID}.UpdaterService.DOWNLOAD_TITLE" - - internal const val NOTIFY_ON_INSTALL_KEY = "notify_on_install_complete" - - private var instance: AppUpdateService? = null - - /** - * Returns the status of the service. - * - * @return true if the service is running, false otherwise. - */ - fun isRunning(): Boolean = instance != null - - /** - * 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, notifyOnInstall: Boolean) { - if (!isRunning()) { - val title = context.getString(R.string.app_name) - val intent = Intent(context, AppUpdateService::class.java).apply { - putExtra(EXTRA_DOWNLOAD_TITLE, title) - putExtra(EXTRA_DOWNLOAD_URL, url) - putExtra(EXTRA_NOTIFY_ON_INSTALL, notifyOnInstall) - } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - context.startService(intent) - } else { - context.startForegroundService(intent) - } - } - } - - /** - * 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, notifyOnInstall: Boolean = false): PendingIntent { - val intent = Intent(context, AppUpdateService::class.java).apply { - putExtra(EXTRA_DOWNLOAD_URL, url) - putExtra(EXTRA_NOTIFY_ON_INSTALL, notifyOnInstall) - } - return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AutoAppUpdaterJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AutoAppUpdaterJob.kt deleted file mode 100644 index c72d644b62..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AutoAppUpdaterJob.kt +++ /dev/null @@ -1,71 +0,0 @@ -package eu.kanade.tachiyomi.data.updater - -import android.content.Context -import android.os.Build -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.ExistingWorkPolicy -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.util.system.isConnectedToWifi -import eu.kanade.tachiyomi.util.system.localeContext -import kotlinx.coroutines.coroutineScope -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -class AutoAppUpdaterJob(private val context: Context, workerParams: WorkerParameters) : - CoroutineWorker(context, workerParams) { - - override suspend fun doWork(): Result = coroutineScope { - try { - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - !context.packageManager.canRequestPackageInstalls() - ) { - return@coroutineScope Result.failure() - } - val preferences = Injekt.get() - if (preferences.appShouldAutoUpdate() == ONLY_ON_UNMETERED && !context.isConnectedToWifi()) { - return@coroutineScope Result.failure() - } - val result = AppUpdateChecker().checkForUpdate(context, true, doExtrasAfterNewUpdate = false) - if (result is AppUpdateResult.NewUpdate && !AppUpdateService.isRunning()) { - AppUpdateNotifier(context.localeContext).cancel() - AppUpdateNotifier.releasePageUrl = result.release.releaseLink - AppUpdateService.start(context, result.release.downloadLink, false) - } - Result.success() - } catch (e: Exception) { - Result.failure() - } - } - - companion object { - private const val TAG = "AutoUpdateRunner" - const val ALWAYS = 0 - const val ONLY_ON_UNMETERED = 1 - const val NEVER = 2 - - fun setupTask(context: Context) { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .setRequiresDeviceIdle(true) - .build() - - val request = OneTimeWorkRequestBuilder() - .addTag(TAG) - .setConstraints(constraints) - .build() - - WorkManager.getInstance(context) - .enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request) - } - - fun cancelTask(context: Context) { - WorkManager.getInstance(context).cancelAllWorkByTag(TAG) - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionInstallService.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionInstallService.kt deleted file mode 100644 index 00f0a4561f..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionInstallService.kt +++ /dev/null @@ -1,201 +0,0 @@ -package eu.kanade.tachiyomi.extension - -import android.app.Service -import android.content.Context -import android.content.Intent -import android.os.IBinder -import android.os.PowerManager -import androidx.work.NetworkType -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.extension.ExtensionManager.ExtensionInfo -import eu.kanade.tachiyomi.extension.model.Extension -import eu.kanade.tachiyomi.extension.model.InstallStep -import eu.kanade.tachiyomi.util.system.acquireWakeLock -import eu.kanade.tachiyomi.util.system.localeContext -import eu.kanade.tachiyomi.util.system.notificationManager -import eu.kanade.tachiyomi.util.system.toast -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get -import java.util.ArrayList -import java.util.concurrent.TimeUnit -import kotlin.math.max - -class ExtensionInstallService( - val extensionManager: ExtensionManager = Injekt.get(), -) : Service() { - - /** - * Wake lock that will be held until the service is destroyed. - */ - private lateinit var wakeLock: PowerManager.WakeLock - - private lateinit var notifier: ExtensionInstallNotifier - - private var job: Job? = null - - private var serviceScope = CoroutineScope(Job() + Dispatchers.Default) - - private val requestSemaphore = Semaphore(3) - - private val preferences: PreferencesHelper = Injekt.get() - - private var activeInstalls = mutableListOf() - - /** - * This method needs to be implemented, but it's not used/needed. - */ - override fun onBind(intent: Intent): IBinder? { - return null - } - - /** - * Method called when the service receives an intent. - * - * @param intent the start intent from. - * @param flags the flags of the command. - * @param startId the start id of this command. - * @return the start value of the command. - */ - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent == null) return START_NOT_STICKY - val showUpdated = intent.getIntExtra(KEY_SHOW_UPDATED, 0) - val showUpdatedNotification = showUpdated > 0 - val reRunUpdateCheck = showUpdated > 1 - - if (!showUpdatedNotification && !preferences.hasPromptedBeforeUpdateAll().get()) { - toast(R.string.some_extensions_may_prompt) - preferences.hasPromptedBeforeUpdateAll().set(true) - } - - instance = this - - val list = intent.getParcelableArrayListExtra(KEY_EXTENSION)?.filter { - val installedExt = extensionManager.installedExtensionsFlow.value.find { installed -> - installed.pkgName == it.pkgName - } ?: return@filter false - installedExt.versionCode < it.versionCode || installedExt.libVersion < it.libVersion - } ?: return START_NOT_STICKY - - activeInstalls = list.map { it.pkgName }.toMutableList() - serviceScope.launch { - list.forEach { extensionManager.setPending(it.pkgName) } - } - var installed = 0 - val installedExtensions = mutableListOf() - job = serviceScope.launch { - val results = list.map { extension -> - async { - requestSemaphore.withPermit { - extensionManager.installExtension(extension, serviceScope) - .collect { - if (it.first.isCompleted()) { - activeInstalls.remove(extension.pkgName) - installedExtensions.add(extension) - installed++ - val prefCount = - preferences.extensionUpdatesCount().get() - preferences.extensionUpdatesCount().set(max(prefCount - 1, 0)) - } - notifier.showProgressNotification(installed, list.size) - if (activeInstalls.isEmpty()) { - job?.cancel() - } - } - } - } - } - results.awaitAll() - } - - job?.invokeOnCompletion { - if (showUpdatedNotification && installedExtensions.size > 0) { - notifier.showUpdatedNotification(installedExtensions, preferences.hideNotificationContent()) - } - if (reRunUpdateCheck || installedExtensions.size != list.size) { - ExtensionUpdateJob.runJobAgain(this, NetworkType.CONNECTED, false) - } - stopSelf(startId) - } - - return START_NOT_STICKY - } - - /** - * Method called when the service is created. It injects dagger dependencies and acquire - * the wake lock. - */ - override fun onCreate() { - super.onCreate() - notificationManager.cancel(Notifications.ID_UPDATES_TO_EXTS) - notifier = ExtensionInstallNotifier(this.localeContext) - wakeLock = acquireWakeLock(timeout = TimeUnit.MINUTES.toMillis(30)) - startForeground(Notifications.ID_EXTENSION_PROGRESS, notifier.progressNotificationBuilder.build()) - } - - /** - * Method called when the service is destroyed. It cancels jobs and releases the wake lock. - */ - override fun onDestroy() { - job?.cancel() - serviceScope.cancel() - activeInstalls.forEach { extensionManager.cleanUpInstallation(it) } - activeInstalls.clear() - extensionManager.downloadRelay.tryEmit("Finished" to (InstallStep.Installed to null)) - if (instance == this) { - instance = null - } - if (wakeLock.isHeld) { - wakeLock.release() - } - super.onDestroy() - } - - companion object { - - private var instance: ExtensionInstallService? = null - - /** - * Stops the service. - * - * @param context the application context. - */ - fun stop(context: Context) { - instance?.serviceScope?.cancel() - context.stopService(Intent(context, ExtensionUpdateJob::class.java)) - } - - fun activeInstalls(): List? = instance?.activeInstalls - - /** - * Returns the status of the service. - * - * @return true if the service is running, false otherwise. - */ - fun isRunning() = instance != null - - /** - * Key that defines what should be updated. - */ - private const val KEY_EXTENSION = "extension" - private const val KEY_SHOW_UPDATED = "show_updated" - - fun jobIntent(context: Context, extensions: List, showUpdatedExtension: Int = 0): Intent { - return Intent(context, ExtensionInstallService::class.java).apply { - val info = extensions.map(::ExtensionInfo) - putParcelableArrayListExtra(KEY_EXTENSION, ArrayList(info)) - putExtra(KEY_SHOW_UPDATED, showUpdatedExtension) - } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionInstallerJob.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionInstallerJob.kt new file mode 100644 index 0000000000..35acc68e38 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionInstallerJob.kt @@ -0,0 +1,205 @@ +package eu.kanade.tachiyomi.extension + +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.extension.model.InstallStep +import eu.kanade.tachiyomi.util.system.jobIsRunning +import eu.kanade.tachiyomi.util.system.launchIO +import eu.kanade.tachiyomi.util.system.localeContext +import eu.kanade.tachiyomi.util.system.notificationManager +import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.system.tryToSetForeground +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.lang.ref.WeakReference +import kotlin.math.max + +class ExtensionInstallerJob(val context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { + + private val notifier = ExtensionInstallNotifier(context.localeContext) + + private val preferences: PreferencesHelper = Injekt.get() + + private var activeInstalls = mutableListOf() + + val extensionManager: ExtensionManager = Injekt.get() + + private var emitScope = CoroutineScope(Job() + Dispatchers.Default) + + private var job: Job? = null + + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = notifier.progressNotificationBuilder.build() + val id = Notifications.ID_EXTENSION_PROGRESS + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(id, notification) + } + } + + override suspend fun doWork(): Result { + tryToSetForeground() + + instance = WeakReference(this) + + context.notificationManager.cancel(Notifications.ID_UPDATES_TO_EXTS) + val showUpdated = inputData.getInt(KEY_SHOW_UPDATED, -1) + val showUpdatedNotification = showUpdated > -1 + val reRunUpdateCheck = showUpdated > 0 + + if (!showUpdatedNotification && !preferences.hasPromptedBeforeUpdateAll().get()) { + context.toast(R.string.some_extensions_may_prompt) + preferences.hasPromptedBeforeUpdateAll().set(true) + } + + val json = inputData.getString(KEY_EXTENSION) ?: return Result.failure() + + val infos = try { + Json.decodeFromString>(json) + } catch (e: Exception) { + Timber.e(e, "Cannot decode string") + null + } ?: return Result.failure() + val list = infos.filter { + val installedExt = extensionManager.installedExtensionsFlow.value.find { installed -> + installed.pkgName == it.pkgName + } ?: return@filter false + installedExt.versionCode < it.versionCode || installedExt.libVersion < it.libVersion + } + + activeInstalls = list.map { it.pkgName }.toMutableList() + emitScope.launch { list.forEach { extensionManager.setPending(it.pkgName) } } + var installed = 0 + val installedExtensions = mutableListOf() + val requestSemaphore = Semaphore(3) + coroutineScope { + job = launchIO { + list.map { extension -> + async { + requestSemaphore.withPermit { + extensionManager.installExtension(extension, this) + .collect { + if (it.first.isCompleted()) { + activeInstalls.remove(extension.pkgName) + installedExtensions.add(extension) + installed++ + val prefCount = preferences.extensionUpdatesCount().get() + preferences.extensionUpdatesCount() + .set(max(prefCount - 1, 0)) + } + notifier.showProgressNotification(installed, list.size) + if (activeInstalls.isEmpty() || isStopped) { + cancel() + } + } + } + } + }.awaitAll() + } + } + + if (showUpdatedNotification && installedExtensions.size > 0) { + notifier.showUpdatedNotification(installedExtensions, preferences.hideNotificationContent()) + } + if (reRunUpdateCheck || installedExtensions.size != list.size) { + ExtensionUpdateJob.runJobAgain(context, NetworkType.CONNECTED, false) + } + + activeInstalls.forEach { extensionManager.cleanUpInstallation(it) } + activeInstalls.clear() + val hasChain = withContext(Dispatchers.IO) { + WorkManager.getInstance(context).getWorkInfosByTag(TAG).get().any { + it.state == WorkInfo.State.BLOCKED + } + } + if (!hasChain) { + extensionManager.emitToInstaller("Finished", (InstallStep.Installed to null)) + } + if (instance?.get() == this) { + instance = null + } + if (!hasChain) { + context.notificationManager.cancel(Notifications.ID_EXTENSION_PROGRESS) + } + return Result.success() + } + + companion object { + private const val TAG = "ExtensionInstaller" + + /** + * Key that defines what should be updated. + */ + const val KEY_EXTENSION = "extension" + const val KEY_SHOW_UPDATED = "show_updated" + + private var instance: WeakReference? = null + + fun start(context: Context, extensions: List, showUpdatedExtension: Int = -1) { + startJob(context, extensions.map(ExtensionManager::ExtensionInfo), showUpdatedExtension) + } + + fun startJob(context: Context, info: List, showUpdatedExtension: Int = -1) { + // chunked to satisfy input limits + val requests = info.chunked(32).map { + OneTimeWorkRequestBuilder() + .addTag(TAG) + .setInputData( + workDataOf( + KEY_EXTENSION to Json.encodeToString(it.toTypedArray()), + KEY_SHOW_UPDATED to showUpdatedExtension, + ), + ) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + } + var workContinuation = WorkManager.getInstance(context) + .beginUniqueWork(TAG, ExistingWorkPolicy.REPLACE, requests.first()) + for (i in 1 until requests.size) { + workContinuation = workContinuation.then(requests[i]) + } + workContinuation.enqueue() + } + + fun activeInstalls(): List? = instance?.get()?.activeInstalls + fun removeActiveInstall(pkgName: String) = instance?.get()?.activeInstalls?.remove(pkgName) + + fun stop(context: Context) { + instance?.get()?.job?.cancel() + WorkManager.getInstance(context).cancelAllWorkByTag(TAG) + } + + fun isRunning(context: Context) = WorkManager.getInstance(context).jobIsRunning(TAG) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt index 765d5d41d7..5db66d864e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -15,7 +15,6 @@ import eu.kanade.tachiyomi.extension.util.ExtensionInstallReceiver import eu.kanade.tachiyomi.extension.util.ExtensionInstaller import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo import eu.kanade.tachiyomi.util.system.launchNow import kotlinx.coroutines.CoroutineScope @@ -56,15 +55,12 @@ class ExtensionManager( private val iconMap = mutableMapOf() - val downloadRelay - get() = installer.downloadsStateFlow + val downloadSharedFlow = installer.downloadSharedFlow private fun getExtension(downloadId: Long): String? { return installer.activeDownloads.entries.find { downloadId == it.value }?.key } - fun getActiveInstalls(): Int = installer.activeDownloads.size - /** * Relay used to notify the installed extensions. */ @@ -73,16 +69,6 @@ class ExtensionManager( private var subLanguagesEnabledOnFirstRun = preferences.enabledLanguages().isSet() - /** - * List of the currently installed extensions. - */ -// private var installedExtensions = emptyList() -// set(value) { -// field = value -// installedExtensionsRelay.call(value) -// downloadRelay.tryEmit("Finished/Installed/${value.size}" to (InstallStep.Done to null)) -// } - fun getAppIconForSource(source: Source): Drawable? { return getAppIconForSource(source.id) } @@ -110,36 +96,9 @@ class ExtensionManager( private var availableSources = hashMapOf() - /** - * List of the currently available extensions. - */ -// var availableExtensions = emptyList() -// private set(value) { -// field = value -// availableExtensionsRelay.call(value) -// updatedInstalledExtensionsStatuses(value) -// downloadRelay.tryEmit("Finished/Available/${value.size}" to (InstallStep.Done to null)) -// setupAvailableSourcesMap() -// } - private val _untrustedExtensionsFlow = MutableStateFlow(emptyList()) val untrustedExtensionsFlow = _untrustedExtensionsFlow.asStateFlow() - /** - * List of the currently untrusted extensions. - */ -// var untrustedExtensions = emptyList() -// private set(value) { -// field = value -// untrustedExtensionsRelay.call(value) -// downloadRelay.tryEmit("Finished/Untrusted/${value.size}" to (InstallStep.Done to null)) -// } - - /** - * The source manager where the sources of the extensions are added. - */ - private lateinit var sourceManager: SourceManager - init { initExtensions() ExtensionInstallReceiver(InstallationListener()).register(context) @@ -180,7 +139,7 @@ class ExtensionManager( _availableExtensionsFlow.value = extensions updatedInstalledExtensionsStatuses(extensions) setupAvailableSourcesMap() - downloadRelay.tryEmit("Finished/Available/${extensions.size}" to (InstallStep.Done to null)) + emitToInstaller("Finished/Available/${extensions.size}", (InstallStep.Done to null)) } /** @@ -239,7 +198,7 @@ class ExtensionManager( val pkgName = installedExt.pkgName val availableExt = availableExtensions.find { it.pkgName == pkgName } - if (availableExt == null != installedExt.isObsolete) { + if (!installedExt.isUnofficial && availableExt == null != installedExt.isObsolete) { mutInstalledExtensions[index] = installedExt.copy(isObsolete = true) changed = true } @@ -304,10 +263,6 @@ class ExtensionManager( */ fun setInstalling(downloadId: Long, sessionId: Int) { val pkgName = getExtension(downloadId) ?: return - setInstalling(pkgName, sessionId) - } - - fun setInstalling(pkgName: String, sessionId: Int) { installer.setInstalling(pkgName, sessionId) } @@ -323,7 +278,7 @@ class ExtensionManager( InstallStep.Installing } installer.activeDownloads[pkgName] != null -> InstallStep.Downloading - ExtensionInstallService.activeInstalls() + ExtensionInstallerJob.activeInstalls() ?.contains(pkgName) == true -> InstallStep.Pending else -> return null } @@ -386,7 +341,10 @@ class ExtensionManager( */ private fun registerNewExtension(extension: Extension.Installed) { _installedExtensionsFlow.value += extension - downloadRelay.tryEmit("Finished/${extension.pkgName}" to ExtensionIntallInfo(InstallStep.Installed, null)) + emitToInstaller( + "Finished/${extension.pkgName}", + ExtensionIntallInfo(InstallStep.Installed, null), + ) } /** @@ -403,9 +361,15 @@ class ExtensionManager( } mutInstalledExtensions += extension _installedExtensionsFlow.value = mutInstalledExtensions - downloadRelay.tryEmit("Finished/${extension.pkgName}" to ExtensionIntallInfo(InstallStep.Installed, null)) + emitToInstaller( + "Finished/${extension.pkgName}", + ExtensionIntallInfo(InstallStep.Installed, null), + ) } + fun emitToInstaller(name: String, extensionInfo: ExtensionIntallInfo) = + installer.emitToFlow(name, extensionInfo) + /** * Unregisters the extension in this and the source managers given its package name. Note this * method is called for every uninstalled application in the system. @@ -421,6 +385,7 @@ class ExtensionManager( if (untrustedExtension != null) { _untrustedExtensionsFlow.value -= untrustedExtension } + installer.emitToFlow("Uninstalled/$pkgName", ExtensionIntallInfo(InstallStep.Done, null)) } /** @@ -462,6 +427,7 @@ class ExtensionManager( return (availableExt.versionCode > versionCode || availableExt.libVersion > libVersion) } + @kotlinx.serialization.Serializable @Parcelize data class ExtensionInfo( val apkName: String, diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt index 9680db78ba..fa0c127795 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt @@ -1,9 +1,9 @@ package eu.kanade.tachiyomi.extension -import android.app.PendingIntent +import android.Manifest import android.content.Context import android.content.pm.PackageManager -import android.os.Build +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -18,16 +18,17 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.updater.AutoAppUpdaterJob +import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.util.system.connectivityManager import eu.kanade.tachiyomi.util.system.localeContext import eu.kanade.tachiyomi.util.system.notification +import eu.kanade.tachiyomi.util.system.toInt import kotlinx.coroutines.coroutineScope import rikka.shizuku.Shizuku import uy.kohesive.injekt.Injekt @@ -72,30 +73,21 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam } if (ExtensionManager.canAutoInstallUpdates(context, true) && inputData.getBoolean(RUN_AUTO, true) && - preferences.autoUpdateExtensions() != AutoAppUpdaterJob.NEVER && - !ExtensionInstallService.isRunning() && + preferences.autoUpdateExtensions() != AppDownloadInstallJob.NEVER && + !ExtensionInstallerJob.isRunning(context) && extensionsInstalledByApp.isNotEmpty() ) { val cm = context.connectivityManager - val libraryServiceRunning = LibraryUpdateService.isRunning() + val libraryServiceRunning = LibraryUpdateJob.isRunning(context) if ( ( - preferences.autoUpdateExtensions() == AutoAppUpdaterJob.ALWAYS || + preferences.autoUpdateExtensions() == AppDownloadInstallJob.ALWAYS || !cm.isActiveNetworkMetered ) && !libraryServiceRunning ) { - val intent = - ExtensionInstallService.jobIntent( - context, - extensionsInstalledByApp, - // Re run this job if not all the extensions can be auto updated - if (extensionsInstalledByApp.size == extensions.size) { - 1 - } else { - 2 - }, - ) - ContextCompat.startForegroundService(context, intent) + // Re-run this job if not all the extensions can be auto updated + val showUpdates = (extensionsInstalledByApp.size != extensions.size).toInt() + ExtensionInstallerJob.start(context, extensionsInstalledByApp, showUpdates) if (extensionsInstalledByApp.size == extensions.size) { return } else { @@ -104,10 +96,15 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam } else if (!libraryServiceRunning) { runJobAgain(context, NetworkType.UNMETERED) } else { - LibraryUpdateService.runExtensionUpdatesAfter = true + LibraryUpdateJob.runExtensionUpdatesAfterJob() } } NotificationManagerCompat.from(context).apply { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + return@apply + } notify( Notifications.ID_UPDATES_TO_EXTS, context.notification(Notifications.CHANNEL_UPDATES_TO_EXTS) { @@ -132,23 +129,8 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam if (ExtensionManager.canAutoInstallUpdates(context, true) && extensions.size == extensionsList.size ) { - val intent = ExtensionInstallService.jobIntent(context, extensions) val pendingIntent = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - PendingIntent.getForegroundService( - context, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - } else { - PendingIntent.getService( - context, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - } + NotificationReceiver.startExtensionUpdatePendingJob(context, extensions) addAction( R.drawable.ic_file_download_24dp, context.getString(R.string.update_all), @@ -202,7 +184,7 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam .setConstraints(constraints) .build() - WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request) + WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request) } else { WorkManager.getInstance(context).cancelAllWorkByTag(TAG) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt index 6a95fc1bca..08292cc107 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt @@ -12,6 +12,7 @@ import android.os.Environment import androidx.core.net.toUri import androidx.preference.PreferenceManager import eu.kanade.tachiyomi.data.preference.PreferenceKeys +import eu.kanade.tachiyomi.extension.ExtensionInstallerJob import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ShizukuInstaller import eu.kanade.tachiyomi.extension.model.InstallStep @@ -24,9 +25,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter @@ -77,7 +78,7 @@ internal class ExtensionInstaller(private val context: Context) { it.onDestroy() ioScope.launch { delay(500) - downloadsStateFlow.emit("Finished" to (InstallStep.Installed to null)) + _downloadsSharedFlow.emit("Finished" to (InstallStep.Installed to null)) } installer = null } @@ -92,10 +93,13 @@ internal class ExtensionInstaller(private val context: Context) { /** * StateFlow used to notify the installation step of every download. */ - val downloadsStateFlow = MutableStateFlow("" to ExtensionIntallInfo(InstallStep.Pending, null)) + private val _downloadsSharedFlow = MutableSharedFlow>() + val downloadSharedFlow = _downloadsSharedFlow.asSharedFlow() /** Map of download id to installer session id */ val downloadInstallerMap = hashMapOf() + fun emitToFlow(name: String, extensionInfo: ExtensionIntallInfo) = + ioScope.launch { _downloadsSharedFlow.emit(name to extensionInfo) } /** * Adds the given extension to the downloads queue and returns a flow containing its @@ -106,7 +110,7 @@ internal class ExtensionInstaller(private val context: Context) { */ suspend fun downloadAndInstall(url: String, extension: ExtensionManager.ExtensionInfo, scope: CoroutineScope): Flow { val pkgName = extension.pkgName - downloadsStateFlow.value + val oldDownload = activeDownloads[pkgName] if (oldDownload != null) { deleteDownload(pkgName) @@ -155,11 +159,11 @@ internal class ExtensionInstaller(private val context: Context) { deleteDownload(pkgName) } .collect { - downloadsStateFlow.emit(extension.pkgName to it) + _downloadsSharedFlow.emit(extension.pkgName to it) } } - return downloadsStateFlow.filter { it.first == extension.pkgName }.map { it.second } + return _downloadsSharedFlow.filter { it.first == extension.pkgName }.map { it.second } .flowOn(Dispatchers.IO) .transformWhile { emit(it) @@ -241,6 +245,7 @@ internal class ExtensionInstaller(private val context: Context) { Timber.e(it) } .onCompletion { + deleteDownload(pkgName) emit(InstallStep.Done to null) } } @@ -293,15 +298,16 @@ internal class ExtensionInstaller(private val context: Context) { /** * Sets the result of the installation of an extension. * - * @param downloadId The id of the download. + * @param pkgName the name of the package being installed + * @param sessionId The id of the package manager's session or other installer. */ fun setInstalling(pkgName: String, sessionId: Int) { - downloadsStateFlow.tryEmit(pkgName to ExtensionIntallInfo(InstallStep.Installing, null)) + emitToFlow(pkgName, ExtensionIntallInfo(InstallStep.Installing, null)) downloadInstallerMap[pkgName] = sessionId } suspend fun setPending(pkgName: String) { - downloadsStateFlow.emit(pkgName to ExtensionIntallInfo(InstallStep.Pending, null)) + _downloadsSharedFlow.emit(pkgName to ExtensionIntallInfo(InstallStep.Pending, null)) } fun cancelInstallation(sessionId: Int) { @@ -327,9 +333,12 @@ internal class ExtensionInstaller(private val context: Context) { * @param result Whether the extension was installed or not. */ fun setInstallationResult(pkgName: String, result: Boolean) { + if (result) { + deleteDownload(pkgName) + } val step = if (result) InstallStep.Installed else InstallStep.Error downloadInstallerMap.remove(pkgName) - downloadsStateFlow.tryEmit(pkgName to ExtensionIntallInfo(step, null)) + emitToFlow(pkgName, ExtensionIntallInfo(step, null)) } /** @@ -342,6 +351,7 @@ internal class ExtensionInstaller(private val context: Context) { if (downloadId != null) { downloadManager.remove(downloadId) } + ExtensionInstallerJob.removeActiveInstall(pkgName) if (activeDownloads.isEmpty()) { downloadReceiver.unregister() } @@ -394,10 +404,10 @@ internal class ExtensionInstaller(private val context: Context) { val pkgName = activeDownloads.entries.find { id == it.value }?.key // Set next installation step if (uri != null && pkgName != null) { - downloadsStateFlow.tryEmit(pkgName to ExtensionIntallInfo(InstallStep.Loading, null)) + emitToFlow(pkgName, ExtensionIntallInfo(InstallStep.Loading, null)) } else if (pkgName != null) { Timber.e("Couldn't locate downloaded APK") - downloadsStateFlow.tryEmit(pkgName to ExtensionIntallInfo(InstallStep.Error, null)) + emitToFlow(pkgName, ExtensionIntallInfo(InstallStep.Error, null)) return } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomPresenter.kt index 18b10967fd..227fb619ad 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomPresenter.kt @@ -69,8 +69,9 @@ class DownloadBottomPresenter : BaseCoroutinePresenter() { /** * Clears the download queue. */ - fun clearQueue() { + fun stopDownloads() { downloadManager.clearQueue() + downloadManager.stopDownloads() } fun reorder(downloads: List) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomSheet.kt index 4928d68e49..de9c528a2a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/download/DownloadBottomSheet.kt @@ -13,7 +13,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import eu.davidea.flexibleadapter.FlexibleAdapter import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.download.DownloadService +import eu.kanade.tachiyomi.data.download.DownloadJob import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.databinding.DownloadBottomSheetBinding @@ -98,9 +98,8 @@ class DownloadBottomSheet @JvmOverloads constructor( } binding.downloadFab.setOnClickListener { if (controller.presenter.downloadManager.isPaused()) { - DownloadService.start(context) + DownloadJob.start(context) } else { - DownloadService.stop(context) presenter.pauseDownloads() } updateFab() @@ -235,11 +234,9 @@ class DownloadBottomSheet @JvmOverloads constructor( } fun onOptionsItemSelected(item: MenuItem): Boolean { - val context = activity ?: return false when (item.itemId) { R.id.clear_queue -> { - DownloadService.stop(context) - presenter.clearQueue() + presenter.stopDownloads() } R.id.newest, R.id.oldest -> { reorderQueue({ it.download.chapter.date_upload }, item.itemId == R.id.newest) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt index 5fabedfa03..2e7d6411dc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt @@ -1,9 +1,8 @@ package eu.kanade.tachiyomi.ui.extension import android.content.pm.PackageInstaller -import androidx.core.content.ContextCompat import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.extension.ExtensionInstallService +import eu.kanade.tachiyomi.extension.ExtensionInstallerJob import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.InstallStep @@ -14,8 +13,7 @@ import eu.kanade.tachiyomi.util.system.withUIContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -26,7 +24,7 @@ typealias ExtensionIntallInfo = Pair /** * Presenter of [ExtensionBottomSheet]. */ -class ExtensionBottomPresenter() : BaseMigrationPresenter() { +class ExtensionBottomPresenter : BaseMigrationPresenter() { private var extensions = emptyList() @@ -52,11 +50,13 @@ class ExtensionBottomPresenter() : BaseMigrationPresenter( listOf(migrationJob, extensionJob).awaitAll() } presenterScope.launch { - extensionManager.downloadRelay.asSharedFlow() + extensionManager.downloadSharedFlow .collect { - if (it.first.startsWith("Finished")) { - firstLoad = true - currentDownloads.clear() + if (it.first.startsWith("Finished") || it.first.startsWith("Uninstalled")) { + if (it.first.startsWith("Finished")) { + firstLoad = true + currentDownloads.clear() + } extensions = toItems( Triple( extensionManager.installedExtensionsFlow.value, @@ -242,7 +242,20 @@ class ExtensionBottomPresenter() : BaseMigrationPresenter( ExtensionManager.ExtensionInfo(extension), presenterScope, ) - .launchIn(this) + .collect { + when (it.first) { + InstallStep.Installed, InstallStep.Error -> { + currentDownloads.remove(extension.pkgName) + } + else -> { + currentDownloads[extension.pkgName] = it + } + } + val item = updateInstallStep(extension, it.first, it.second) + if (item != null) { + withUIContext { view?.downloadUpdate(item) } + } + } } } @@ -261,13 +274,12 @@ class ExtensionBottomPresenter() : BaseMigrationPresenter( val item = updateInstallStep(it, InstallStep.Pending, null) ?: return@forEach view?.downloadUpdate(item) } - val intent = ExtensionInstallService.jobIntent( + ExtensionInstallerJob.start( context, extensions.mapNotNull { extension -> extensionManager.availableExtensionsFlow.value.find { it.pkgName == extension.pkgName } }, ) - ContextCompat.startForegroundService(context, intent) } fun uninstallExtension(pkgName: String) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt index 14ab16ab5c..c4c10f8596 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt @@ -19,8 +19,10 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.databinding.ExtensionsBottomSheetBinding import eu.kanade.tachiyomi.databinding.RecyclerWithScrollerBinding import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.extension.model.InstalledExtensionsOrder import eu.kanade.tachiyomi.ui.extension.details.ExtensionDetailsController +import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.migration.BaseMigrationInterface import eu.kanade.tachiyomi.ui.migration.MangaAdapter import eu.kanade.tachiyomi.ui.migration.MangaItem @@ -217,6 +219,7 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At } override fun onUpdateAllClicked(position: Int) { + (controller.activity as? MainActivity)?.showNotificationPermissionPrompt() if (!presenter.preferences.useShizukuForExtensions() && !presenter.preferences.hasPromptedBeforeUpdateAll().get() ) { @@ -245,13 +248,13 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At } } - fun updateAllExtensions(position: Int) { + private fun updateAllExtensions(position: Int) { val header = (extAdapter?.getSectionHeader(position)) as? ExtensionGroupItem ?: return val items = extAdapter?.getSectionItemPositions(header) val extensions = items?.mapNotNull { val extItem = (extAdapter?.getItem(it) as? ExtensionItem) ?: return val extension = (extAdapter?.getItem(it) as? ExtensionItem)?.extension ?: return - if (extItem.installStep == null && + if ((extItem.installStep == null || extItem.installStep == InstallStep.Error) && extension is Extension.Installed && extension.hasUpdate ) { extension @@ -392,7 +395,7 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At val items = extAdapter?.getSectionItemPositions(updateHeader) ?: return updateHeader.canUpdate = items.any { val extItem = (extAdapter?.getItem(it) as? ExtensionItem) ?: return - extItem.installStep == null + extItem.installStep == null || extItem.installStep == InstallStep.Error } extAdapter?.updateItem(updateHeader) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt index e1b12833a3..dd0168c52f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt @@ -61,9 +61,8 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.LibraryManga import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.download.DownloadService -import eu.kanade.tachiyomi.data.library.LibraryServiceListener -import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.download.DownloadJob +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper @@ -148,8 +147,7 @@ open class LibraryController( LibraryCategoryAdapter.LibraryListener, BottomSheetController, RootSearchInterface, - FloatingSearchInterface, - LibraryServiceListener { + FloatingSearchInterface { init { setHasOptionsMenu(true) @@ -598,6 +596,7 @@ open class LibraryController( setupFilterSheet() setUpHopper() setPreferenceFlows() + LibraryUpdateJob.updateFlow.onEach(::onUpdateManga).launchIn(viewScope) elevateAppBar = scrollViewWith( @@ -676,7 +675,7 @@ open class LibraryController( private fun setSwipeRefresh() = with(binding.swipeRefresh) { setOnRefreshListener { isRefreshing = false - if (!LibraryUpdateService.isRunning()) { + if (!LibraryUpdateJob.isRunning(context)) { when { !presenter.showAllCategories && presenter.groupType == BY_DEFAULT -> { presenter.allCategories.find { it.id == presenter.currentCategory }?.let { @@ -942,12 +941,12 @@ open class LibraryController( private fun updateLibrary(category: Category? = null) { val view = view ?: return - LibraryUpdateService.start(view.context, category) + LibraryUpdateJob.startNow(view.context, category) snack = view.snack(R.string.updating_library) { anchorView = anchorView() view.elevation = 15f.dpToPx setAction(R.string.cancel) { - LibraryUpdateService.stop(context) + LibraryUpdateJob.stop(context) viewScope.launchUI { NotificationReceiver.dismissNotification( context, @@ -1031,8 +1030,7 @@ open class LibraryController( if (type == ControllerChangeType.POP_ENTER) { presenter.getLibrary() } - DownloadService.callListeners() - LibraryUpdateService.setListener(this) + DownloadJob.callListeners() binding.recyclerCover.isClickable = false binding.recyclerCover.isFocusable = false singleCategory = presenter.categories.size <= 1 @@ -1067,7 +1065,6 @@ open class LibraryController( } override fun onDestroyView(view: View) { - LibraryUpdateService.removeListener(this) destroyActionModeIfNeeded() if (isBindingInitialized) { binding.libraryGridRecycler.recycler.removeOnScrollListener(scrollListener) @@ -1088,6 +1085,9 @@ open class LibraryController( view ?: return destroyActionModeIfNeeded() if (mangaMap.isNotEmpty()) { + if (!binding.progress.isVisible) { + (activity as? MainActivity)?.showNotificationPermissionPrompt() + } binding.emptyView.hide() } else { binding.emptyView.show( @@ -1584,9 +1584,9 @@ open class LibraryController( } } - override fun onUpdateManga(manga: Manga?) { - if (manga?.source == LibraryUpdateService.STARTING_UPDATE_SOURCE) return - if (manga == null) { + private fun onUpdateManga(mangaId: Long?) { + if (mangaId == LibraryUpdateJob.STARTING_UPDATE_SOURCE) return + if (mangaId == null) { adapter.getHeaderPositions().forEach { adapter.notifyItemChanged(it) } } else { presenter.updateManga() @@ -1703,13 +1703,13 @@ open class LibraryController( override fun updateCategory(position: Int): Boolean { val category = (adapter.getItem(position) as? LibraryHeaderItem)?.category ?: return false - val inQueue = LibraryUpdateService.categoryInQueue(category.id) + val inQueue = LibraryUpdateJob.categoryInQueue(category.id) snack?.dismiss() snack = view?.snack( resources!!.getString( when { inQueue -> R.string._already_in_queue - LibraryUpdateService.isRunning() -> R.string.adding_category_to_queue + LibraryUpdateJob.isRunning(view!!.context) -> R.string.adding_category_to_queue else -> R.string.updating_ }, category.name, @@ -1719,7 +1719,7 @@ open class LibraryController( anchorView = anchorView() view.elevation = 15f.dpToPx setAction(R.string.cancel) { - LibraryUpdateService.stop(context) + LibraryUpdateJob.stop(context) viewScope.launchUI { NotificationReceiver.dismissNotification( context, @@ -1729,7 +1729,7 @@ open class LibraryController( } } if (!inQueue) { - LibraryUpdateService.start( + LibraryUpdateJob.startNow( view!!.context, category, mangaToUse = if (category.isDynamic) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderHolder.kt index 17201a5a2c..3f08947e1f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryHeaderHolder.kt @@ -19,7 +19,7 @@ import com.github.florent37.viewtooltip.ViewTooltip import eu.davidea.flexibleadapter.SelectableAdapter import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Category -import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.databinding.LibraryCategoryHeaderItemBinding import eu.kanade.tachiyomi.source.icon @@ -210,7 +210,7 @@ class LibraryHeaderHolder(val view: View, val adapter: LibraryCategoryAdapter) : setRefreshing(false) binding.updateButton.isVisible = false } - LibraryUpdateService.categoryInQueue(category.id) -> { + LibraryUpdateJob.categoryInQueue(category.id) -> { binding.collapseArrow.isVisible = !adapter.isSingleCategory binding.checkbox.isVisible = false binding.updateButton.isVisible = true diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index fab228d093..a2677e8f91 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.ui.main +import android.Manifest import android.animation.AnimatorSet import android.animation.ValueAnimator import android.annotation.SuppressLint @@ -8,6 +9,7 @@ import android.app.assist.AssistContent import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.graphics.Color import android.graphics.Rect import android.net.Uri @@ -25,6 +27,7 @@ import android.view.Window import android.view.WindowManager import androidx.activity.OnBackPressedCallback import androidx.activity.addCallback +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.IdRes import androidx.appcompat.view.ActionMode import androidx.appcompat.view.menu.ActionMenuItemView @@ -32,6 +35,7 @@ import androidx.appcompat.view.menu.MenuItemImpl import androidx.appcompat.widget.ActionMenuView import androidx.appcompat.widget.Toolbar import androidx.core.animation.doOnEnd +import androidx.core.app.ActivityCompat import androidx.core.content.getSystemService import androidx.core.graphics.ColorUtils import androidx.core.net.toUri @@ -64,10 +68,9 @@ import com.google.common.primitives.Ints.max import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.Migrations import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.download.DownloadJob import eu.kanade.tachiyomi.data.download.DownloadManager -import eu.kanade.tachiyomi.data.download.DownloadService -import eu.kanade.tachiyomi.data.download.DownloadServiceListener -import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn @@ -107,6 +110,7 @@ import eu.kanade.tachiyomi.util.system.isBottomTappable import eu.kanade.tachiyomi.util.system.isInNightMode import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.launchUI +import eu.kanade.tachiyomi.util.system.materialAlertDialog import eu.kanade.tachiyomi.util.system.prepareSideNavContext import eu.kanade.tachiyomi.util.system.rootWindowInsetsCompat import eu.kanade.tachiyomi.util.system.toast @@ -122,6 +126,8 @@ import eu.kanade.tachiyomi.util.view.withFadeInTransaction import eu.kanade.tachiyomi.util.view.withFadeTransaction import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -133,7 +139,7 @@ import kotlin.math.min import kotlin.math.roundToLong @SuppressLint("ResourceType") -open class MainActivity : BaseActivity(), DownloadServiceListener { +open class MainActivity : BaseActivity() { protected lateinit var router: Router @@ -171,6 +177,17 @@ open class MainActivity : BaseActivity(), DownloadServiceLi dimenW to dimenH } + private val requestNotificationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (!isGranted) { + materialAlertDialog() + .setTitle(R.string.warning) + .setMessage(R.string.allow_notifications_recommended) + .setPositiveButton(android.R.string.ok) { _, _ -> } + .show() + } + } + fun setUndoSnackBar(snackBar: Snackbar?, extraViewToCheck: View? = null) { this.snackBar = snackBar canDismissSnackBar = false @@ -251,12 +268,12 @@ open class MainActivity : BaseActivity(), DownloadServiceLi } var continueSwitchingTabs = false nav.getItemView(R.id.nav_library)?.setOnLongClickListener { - if (!LibraryUpdateService.isRunning()) { - LibraryUpdateService.start(this) + if (!LibraryUpdateJob.isRunning(this)) { + LibraryUpdateJob.startNow(this) binding.mainContent.snack(R.string.updating_library) { anchorView = binding.bottomNav setAction(R.string.cancel) { - LibraryUpdateService.stop(context) + LibraryUpdateJob.stop(context) lifecycleScope.launchUI { NotificationReceiver.dismissNotification( context, @@ -283,7 +300,8 @@ open class MainActivity : BaseActivity(), DownloadServiceLi val container: ViewGroup = binding.controllerContainer val content: ViewGroup = binding.mainContent - DownloadService.addListener(this) + DownloadJob.downloadFlow.onEach(::downloadStatusChanged).launchIn(lifecycleScope) + lifecycleScope WindowCompat.setDecorFitsSystemWindows(window, false) setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayShowCustomEnabled(true) @@ -748,7 +766,7 @@ open class MainActivity : BaseActivity(), DownloadServiceLi checkForAppUpdates() getExtensionUpdates(false) setExtensionsBadge() - DownloadService.callListeners() + DownloadJob.callListeners(downloadManager = downloadManager) showDLQueueTutorial() reEnableBackPressedCallBack() } @@ -809,6 +827,7 @@ open class MainActivity : BaseActivity(), DownloadServiceLi // Create confirmation window withContext(Dispatchers.Main) { + showNotificationPermissionPrompt() AppUpdateNotifier.releasePageUrl = result.release.releaseLink AboutController.NewUpdateDialogController(body, url, isBeta).showDialog(router) } @@ -833,12 +852,24 @@ open class MainActivity : BaseActivity(), DownloadServiceLi ) preferences.extensionUpdatesCount().set(pendingUpdates.size) preferences.lastExtCheck().set(Date().time) - } catch (e: java.lang.Exception) { + } catch (_: Exception) { } } } } + fun showNotificationPermissionPrompt(showAnyway: Boolean = false) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + val notificationPermission = Manifest.permission.POST_NOTIFICATIONS + val hasPermission = ActivityCompat.checkSelfPermission(this, notificationPermission) + if (hasPermission != PackageManager.PERMISSION_GRANTED && + (!preferences.hasShownNotifPermission().get() || showAnyway) + ) { + preferences.hasShownNotifPermission().set(true) + requestNotificationPermissionLauncher.launch((notificationPermission)) + } + } + override fun onNewIntent(intent: Intent) { if (!handleIntentAction(intent)) { super.onNewIntent(intent) @@ -941,7 +972,6 @@ open class MainActivity : BaseActivity(), DownloadServiceLi super.onDestroy() overflowDialog?.dismiss() overflowDialog = null - DownloadService.removeListener(this) if (isBindingInitialized) { binding.appBar.mainActivity = null binding.toolbar.setNavigationOnClickListener(null) @@ -1302,9 +1332,9 @@ open class MainActivity : BaseActivity(), DownloadServiceLi } } - override fun downloadStatusChanged(downloading: Boolean) { - val hasQueue = downloading || downloadManager.hasQueue() - launchUI { + private fun downloadStatusChanged(downloading: Boolean) { + lifecycleScope.launchUI { + val hasQueue = downloading || downloadManager.hasQueue() if (hasQueue) { nav.getOrCreateBadge(R.id.nav_recents) showDLQueueTutorial() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt index 86463c78e5..616d3c9d7e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt @@ -52,7 +52,7 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.download.DownloadService +import eu.kanade.tachiyomi.data.download.DownloadJob import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.image.coil.getBestColor import eu.kanade.tachiyomi.data.notification.NotificationReceiver @@ -1442,7 +1442,7 @@ class MangaDetailsController : presenter.deleteChapter(chapter) } else { if (chapter.status == Download.State.ERROR) { - DownloadService.start(view.context) + DownloadJob.start(view.context) } else { downloadChapters(listOf(chapter)) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt index eaf5087e6d..5987e0270d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt @@ -21,8 +21,7 @@ import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.DownloadQueue import eu.kanade.tachiyomi.data.library.CustomMangaManager -import eu.kanade.tachiyomi.data.library.LibraryServiceListener -import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.data.track.TrackManager @@ -58,6 +57,9 @@ import eu.kanade.tachiyomi.widget.TriStateCheckBox import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -78,7 +80,7 @@ class MangaDetailsPresenter( val db: DatabaseHelper = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(), chapterFilter: ChapterFilter = Injekt.get(), -) : BaseCoroutinePresenter(), DownloadQueue.DownloadListener, LibraryServiceListener { +) : BaseCoroutinePresenter(), DownloadQueue.DownloadListener { private val customMangaManager: CustomMangaManager by injectLazy() private val mangaShortcutManager: MangaShortcutManager by injectLazy() @@ -113,10 +115,12 @@ class MangaDetailsPresenter( tabletChapterHeaderItem = MangaHeaderItem(manga, false) tabletChapterHeaderItem?.isChapterHeader = true } - isLockedFromSearch = controller.shouldLockIfNeeded && SecureActivityDelegate.shouldBeLocked() + isLockedFromSearch = + controller.shouldLockIfNeeded && SecureActivityDelegate.shouldBeLocked() headerItem.isLocked = isLockedFromSearch downloadManager.addListener(this) - LibraryUpdateService.setListener(this) + LibraryUpdateJob.updateFlow.filter { it == manga.id } + .onEach(::onUpdateManga).launchIn(presenterScope) tracks = db.getTracks(manga).executeAsBlocking() if (manga.isLocal()) { refreshAll() @@ -138,7 +142,6 @@ class MangaDetailsPresenter( override fun onDestroy() { super.onDestroy() downloadManager.removeListener(this) - LibraryUpdateService.removeListener(this) } fun fetchChapters(andTracking: Boolean = true) { @@ -694,11 +697,7 @@ class MangaDetailsPresenter( } } - override fun onUpdateManga(manga: Manga?) { - if (manga?.id == this.manga.id) { - fetchChapters() - } - } + private fun onUpdateManga(mangaId: Long?) = fetchChapters() fun shareManga() { val context = Injekt.get() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/AboutController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/AboutController.kt index b8e1a4c752..d83acca506 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/AboutController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/AboutController.kt @@ -15,10 +15,10 @@ import androidx.preference.PreferenceScreen import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob import eu.kanade.tachiyomi.data.updater.AppUpdateChecker import eu.kanade.tachiyomi.data.updater.AppUpdateNotifier import eu.kanade.tachiyomi.data.updater.AppUpdateResult -import eu.kanade.tachiyomi.data.updater.AppUpdateService import eu.kanade.tachiyomi.data.updater.RELEASE_URL import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.setting.SettingsController @@ -214,7 +214,7 @@ class AboutController : SettingsController() { if (appContext != null) { // Start download val url = args.getString(URL_KEY) ?: "" - AppUpdateService.start(appContext, url, true) + AppDownloadInstallJob.start(appContext, url, true) } } .setNegativeButton(R.string.ignore, null) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsController.kt index 272c591f93..4736da0f13 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsController.kt @@ -29,14 +29,14 @@ import com.google.android.material.snackbar.Snackbar import com.google.android.material.tabs.TabLayout import eu.davidea.flexibleadapter.FlexibleAdapter import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.backup.BackupRestoreService +import eu.kanade.tachiyomi.data.backup.BackupRestoreJob import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.ChapterHistory import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.download.DownloadService +import eu.kanade.tachiyomi.data.download.DownloadJob import eu.kanade.tachiyomi.data.download.model.Download -import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.databinding.RecentsControllerBinding @@ -343,9 +343,9 @@ class RecentsController(bundle: Bundle? = null) : } }, ) - binding.swipeRefresh.isRefreshing = LibraryUpdateService.isRunning() + binding.swipeRefresh.isRefreshing = LibraryUpdateJob.isRunning(view.context) binding.swipeRefresh.setOnRefreshListener { - if (!LibraryUpdateService.isRunning()) { + if (!LibraryUpdateJob.isRunning(view.context)) { snack?.dismiss() snack = view.snack(R.string.updating_library) { anchorView = @@ -355,7 +355,7 @@ class RecentsController(bundle: Bundle? = null) : activityBinding?.bottomNav ?: binding.downloadBottomSheet.root } setAction(R.string.cancel) { - LibraryUpdateService.stop(context) + LibraryUpdateJob.stop(context) viewScope.launchUI { NotificationReceiver.dismissNotification( context, @@ -367,12 +367,12 @@ class RecentsController(bundle: Bundle? = null) : object : BaseTransientBottomBar.BaseCallback() { override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { super.onDismissed(transientBottomBar, event) - binding.swipeRefresh.isRefreshing = LibraryUpdateService.isRunning() + binding.swipeRefresh.isRefreshing = LibraryUpdateJob.isRunning(view.context) } }, ) } - LibraryUpdateService.start(view.context) + LibraryUpdateJob.startNow(view.context) } } ogRadius = view.resources.getDimension(R.dimen.rounded_radius) @@ -510,9 +510,12 @@ class RecentsController(bundle: Bundle? = null) : shouldMoveToTop: Boolean = false, ) { if (view == null) return + if (!binding.progress.isVisible && recents.isNotEmpty()) { + (activity as? MainActivity)?.showNotificationPermissionPrompt() + } binding.progress.isVisible = false binding.frameLayout.alpha = 1f - binding.swipeRefresh.isRefreshing = LibraryUpdateService.isRunning() + binding.swipeRefresh.isRefreshing = LibraryUpdateJob.isRunning(view!!.context) adapter.removeAllScrollableHeaders() adapter.updateDataSet(recents) adapter.onLoadMoreComplete(null) @@ -605,7 +608,7 @@ class RecentsController(bundle: Bundle? = null) : presenter.deleteChapter(chapter, manga) } else { if (item.status == Download.State.ERROR) { - DownloadService.start(view.context) + DownloadJob.start(view.context) } else { presenter.downloadChapter(manga, chapter) } @@ -626,7 +629,7 @@ class RecentsController(bundle: Bundle? = null) : presenter.deleteChapter(chapter, manga) } else { if (status == Download.State.ERROR) { - DownloadService.start(view.context) + DownloadJob.start(view.context) } else { presenter.downloadChapter(manga, chapter) } @@ -931,7 +934,7 @@ class RecentsController(bundle: Bundle? = null) : override fun onLoadMore(lastPosition: Int, currentPage: Int) { val view = view ?: return if (presenter.finished || - BackupRestoreService.isRunning(view.context.applicationContext) || + BackupRestoreJob.isRunning(view.context.applicationContext) || (presenter.viewType == RecentsViewType.GroupedAll && !isSearching()) ) { loadNoMore() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsPresenter.kt index 5d4e610057..35d94a2903 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recents/RecentsPresenter.kt @@ -8,13 +8,11 @@ import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.HistoryImpl import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory +import eu.kanade.tachiyomi.data.download.DownloadJob import eu.kanade.tachiyomi.data.download.DownloadManager -import eu.kanade.tachiyomi.data.download.DownloadService -import eu.kanade.tachiyomi.data.download.DownloadServiceListener import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.DownloadQueue -import eu.kanade.tachiyomi.data.library.LibraryServiceListener -import eu.kanade.tachiyomi.data.library.LibraryUpdateService +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.base.presenter.BaseCoroutinePresenter @@ -49,7 +47,7 @@ class RecentsPresenter( val downloadManager: DownloadManager = Injekt.get(), val db: DatabaseHelper = Injekt.get(), private val chapterFilter: ChapterFilter = Injekt.get(), -) : BaseCoroutinePresenter(), DownloadQueue.DownloadListener, LibraryServiceListener, DownloadServiceListener { +) : BaseCoroutinePresenter(), DownloadQueue.DownloadListener { private var recentsJob: Job? = null var recentItems = listOf() @@ -89,8 +87,8 @@ class RecentsPresenter( override fun onCreate() { super.onCreate() downloadManager.addListener(this) - DownloadService.addListener(this) - LibraryUpdateService.setListener(this) + DownloadJob.downloadFlow.onEach(::downloadStatusChanged).launchIn(presenterScope) + LibraryUpdateJob.updateFlow.onEach(::onUpdateManga).launchIn(presenterScope) if (lastRecents != null) { if (recentItems.isEmpty()) { recentItems = lastRecents ?: emptyList() @@ -466,8 +464,6 @@ class RecentsPresenter( override fun onDestroy() { super.onDestroy() downloadManager.removeListener(this) - LibraryUpdateService.removeListener(this) - DownloadService.removeListener(this) lastRecents = recentItems } @@ -534,20 +530,18 @@ class RecentsPresenter( } } - override fun downloadStatusChanged(downloading: Boolean) { - presenterScope.launch { - withContext(Dispatchers.Main) { - view?.updateDownloadStatus(downloading) - } + private fun downloadStatusChanged(downloading: Boolean) { + presenterScope.launchUI { + view?.updateDownloadStatus(downloading) } } - override fun onUpdateManga(manga: Manga?) { - when { - manga == null -> { + private fun onUpdateManga(mangaId: Long?) { + when (mangaId) { + null -> { presenterScope.launchUI { view?.setRefreshing(false) } } - manga.source == LibraryUpdateService.STARTING_UPDATE_SOURCE -> { + LibraryUpdateJob.STARTING_UPDATE_SOURCE -> { presenterScope.launchUI { view?.setRefreshing(true) } } else -> { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt index 424684c3f7..706e9b2001 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt @@ -24,8 +24,8 @@ import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadProvider -import eu.kanade.tachiyomi.data.library.LibraryUpdateService -import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Target +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob +import eu.kanade.tachiyomi.data.library.LibraryUpdateJob.Target import eu.kanade.tachiyomi.data.preference.PreferenceKeys import eu.kanade.tachiyomi.extension.ShizukuInstaller import eu.kanade.tachiyomi.network.NetworkHelper @@ -329,14 +329,14 @@ class SettingsAdvancedController : SettingsController() { titleRes = R.string.refresh_library_metadata summaryRes = R.string.updates_covers_genres_desc - onClick { LibraryUpdateService.start(context, target = Target.DETAILS) } + onClick { LibraryUpdateJob.startNow(context, target = Target.DETAILS) } } preference { key = "refresh_teacking_meta" titleRes = R.string.refresh_tracking_metadata summaryRes = R.string.updates_tracking_details - onClick { LibraryUpdateService.start(context, target = Target.TRACKING) } + onClick { LibraryUpdateJob.startNow(context, target = Target.TRACKING) } } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt index 792d980363..d1192d6c99 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt @@ -20,7 +20,7 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.backup.BackupConst import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.backup.BackupFileValidator -import eu.kanade.tachiyomi.data.backup.BackupRestoreService +import eu.kanade.tachiyomi.data.backup.BackupRestoreJob import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.main.MainActivity @@ -77,7 +77,7 @@ class SettingsBackupController : SettingsController() { context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG) } - if (!BackupRestoreService.isRunning(context)) { + if (!BackupRestoreJob.isRunning(context)) { (activity as? MainActivity)?.getExtensionUpdates(true) val intent = Intent(Intent.ACTION_GET_CONTENT) intent.addCategory(Intent.CATEGORY_OPENABLE) @@ -187,6 +187,7 @@ class SettingsBackupController : SettingsController() { BackupCreatorJob.startNow(activity, uri, backupFlags) } CODE_BACKUP_RESTORE -> { + (activity as? MainActivity)?.showNotificationPermissionPrompt(true) RestoreBackupDialog(uri).showDialog(router) } } @@ -284,7 +285,7 @@ class SettingsBackupController : SettingsController() { val context = applicationContext if (context != null) { activity.toast(R.string.restoring_backup) - BackupRestoreService.start(context, uri) + BackupRestoreJob.start(context, uri) } }.create() } catch (e: Exception) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt index 64a2a1ad5c..a56447e94a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt @@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferenceKeys import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn -import eu.kanade.tachiyomi.data.updater.AutoAppUpdaterJob +import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionUpdateJob import eu.kanade.tachiyomi.source.SourceManager @@ -59,7 +59,7 @@ class SettingsBrowseController : SettingsController() { R.string.over_wifi_only, R.string.dont_auto_update, ) - defaultValue = AutoAppUpdaterJob.ONLY_ON_UNMETERED + defaultValue = AppDownloadInstallJob.ONLY_ON_UNMETERED } val infoPref = if (!preferences.useShizukuForExtensions()) { infoPreference(R.string.some_extensions_may_not_update) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt index 966b85316b..63c86c7617 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt @@ -11,7 +11,7 @@ import androidx.core.os.LocaleListCompat import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.data.updater.AutoAppUpdaterJob +import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob import eu.kanade.tachiyomi.util.lang.addBetaTag import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.system.systemLangContext @@ -119,7 +119,7 @@ class SettingsGeneralController : SettingsController() { titleRes = R.string.auto_update_app entryRange = 0..2 entriesRes = arrayOf(R.string.over_any_network, R.string.over_wifi_only, R.string.dont_auto_update) - defaultValue = AutoAppUpdaterJob.ONLY_ON_UNMETERED + defaultValue = AppDownloadInstallJob.ONLY_ON_UNMETERED } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsLibraryController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsLibraryController.kt index b82446624c..e54cab9969 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsLibraryController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsLibraryController.kt @@ -16,6 +16,7 @@ import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn import eu.kanade.tachiyomi.ui.category.CategoryController import eu.kanade.tachiyomi.ui.library.LibraryPresenter import eu.kanade.tachiyomi.ui.library.display.TabbedLibraryDisplaySheet +import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.launchUI import eu.kanade.tachiyomi.util.view.withFadeTransaction @@ -125,6 +126,7 @@ class SettingsLibraryController : SettingsController() { val interval = newValue as Int if (interval > 0) { + (activity as? MainActivity)?.showNotificationPermissionPrompt(true) LibraryUpdateJob.setupTask(context, interval) } true diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt index aa4498132d..c5c72310e4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt @@ -22,6 +22,7 @@ import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.ui.category.addtolibrary.SetCategoriesSheet +import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.manga.MangaDetailsController import eu.kanade.tachiyomi.ui.migration.MigrationFlags import eu.kanade.tachiyomi.ui.migration.manga.process.MigrationProcessAdapter @@ -180,6 +181,7 @@ fun Manga.addOrRemoveToFavorites( db.insertManga(this).executeAsBlocking() val mc = MangaCategory.create(this, defaultCategory) db.setMangaCategories(listOf(mc), listOf(this)) + (activity as? MainActivity)?.showNotificationPermissionPrompt() onMangaMoved() return view.snack(activity.getString(R.string.added_to_, defaultCategory.name)) { setAction(R.string.change) { @@ -194,6 +196,7 @@ fun Manga.addOrRemoveToFavorites( db.insertManga(this).executeAsBlocking() db.setMangaCategories(emptyList(), listOf(this)) onMangaMoved() + (activity as? MainActivity)?.showNotificationPermissionPrompt() return if (categories.isNotEmpty()) { view.snack(activity.getString(R.string.added_to_, activity.getString(R.string.default_value))) { setAction(R.string.change) { @@ -215,6 +218,7 @@ fun Manga.addOrRemoveToFavorites( ids, true, ) { + (activity as? MainActivity)?.showNotificationPermissionPrompt() onMangaAdded(null) autoAddTrack(db, onMangaMoved) }.show() diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt index 540982c72e..3649f00e7e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/storage/DiskUtil.kt @@ -122,4 +122,6 @@ object DiskUtil { else -> true } } + + const val NOMEDIA_FILE = ".nomedia" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt index 51212001e7..6b317f6063 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt @@ -1,13 +1,10 @@ package eu.kanade.tachiyomi.util.system -import android.app.ActivityManager import android.app.LocaleManager import android.app.Notification import android.app.NotificationManager -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.content.pm.PackageManager import android.content.pm.ResolveInfo import android.content.res.Configuration @@ -35,11 +32,15 @@ import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.net.toUri +import androidx.work.CoroutineWorker +import androidx.work.WorkInfo +import androidx.work.WorkManager import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.ui.main.MainActivity +import timber.log.Timber import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.io.File @@ -71,7 +72,7 @@ fun Context.toast(text: String?, duration: Int = Toast.LENGTH_SHORT) { /** * Helper method to create a notification. * - * @param id the channel id. + * @param channelId the channel id. * @param func the function that will execute inside the builder. * @return a notification to be displayed or updated. */ @@ -162,7 +163,7 @@ val Context.animatorDurationScale: Float /** * Helper method to create a notification builder. * - * @param id the channel id. + * @param channelId the channel id. * @param block the function that will execute inside the builder. * @return a notification to be displayed or updated. */ @@ -210,7 +211,7 @@ fun Context.withOriginalWidth(): Context { fun Context.extensionIntentForText(text: String): Intent? { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(text)) - val info = packageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL) + val info = packageManager.queryIntentActivitiesCompat(intent, PackageManager.MATCH_ALL) .firstOrNull { try { val pkgName = it.activityInfo.packageName @@ -227,19 +228,6 @@ fun Context.isLandscape(): Boolean { return resources.configuration?.orientation == Configuration.ORIENTATION_LANDSCAPE } -/** - * Convenience method to acquire a partial wake lock. - */ -fun Context.acquireWakeLock(tag: String? = null, timeout: Long? = null): PowerManager.WakeLock { - val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "${tag ?: javaClass.name}:WakeLock") - if (timeout != null) { - wakeLock.acquire(timeout) - } else { - wakeLock.acquire() - } - return wakeLock -} - /** * Gets document size of provided [Uri] * @@ -254,9 +242,9 @@ fun Context.getUriSize(uri: Uri): Long? { */ fun Context.isPackageInstalled(packageName: String): Boolean { return try { - packageManager.getApplicationInfo(packageName, 0) + packageManager.getApplicationInfoCompat(packageName, 0) true - } catch (e: Exception) { + } catch (_: Exception) { false } } @@ -282,51 +270,6 @@ val Context.wifiManager: WifiManager val Context.powerManager: PowerManager get() = getSystemService()!! -/** - * Function used to send a local broadcast asynchronous - * - * @param intent intent that contains broadcast information - */ -fun Context.sendLocalBroadcast(intent: Intent) { - androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(this).sendBroadcast( - intent, - ) -} - -/** - * Function used to send a local broadcast synchronous - * - * @param intent intent that contains broadcast information - */ -fun Context.sendLocalBroadcastSync(intent: Intent) { - androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(this).sendBroadcastSync( - intent, - ) -} - -/** - * Function used to register local broadcast - * - * @param receiver receiver that gets registered. - */ -fun Context.registerLocalReceiver(receiver: BroadcastReceiver, filter: IntentFilter) { - androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(this).registerReceiver( - receiver, - filter, - ) -} - -/** - * Function used to unregister local broadcast - * - * @param receiver receiver that gets unregistered. - */ -fun Context.unregisterLocalReceiver(receiver: BroadcastReceiver) { - androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(this).unregisterReceiver( - receiver, - ) -} - /** * Returns true if device is connected to Wifi. */ @@ -345,17 +288,6 @@ fun Context.isConnectedToWifi(): Boolean { } } -/** - * Returns true if the given service class is running. - */ -fun Context.isServiceRunning(serviceClass: Class<*>): Boolean { - val className = serviceClass.name - val manager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - @Suppress("DEPRECATION") - return manager.getRunningServices(Integer.MAX_VALUE) - .any { className == it.service.className } -} - fun Context.openInBrowser(url: String, @ColorInt toolbarColor: Int? = null, forceBrowser: Boolean = false) { this.openInBrowser(url.toUri(), toolbarColor, forceBrowser) } @@ -417,13 +349,7 @@ fun Context.openInBrowser(url: String, forceBrowser: Boolean, fullBrowser: Boole fun Context.defaultBrowserPackageName(): String? { val browserIntent = Intent(Intent.ACTION_VIEW, "http://".toUri()) - val resolveInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.resolveActivity(browserIntent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())) - } else { - @Suppress("DEPRECATION") - packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY) - } - return resolveInfo + return packageManager.resolveActivityCompat(browserIntent, PackageManager.MATCH_DEFAULT_ONLY) ?.activityInfo?.packageName ?.takeUnless { it in DeviceUtil.invalidDefaultBrowsers } } @@ -432,18 +358,17 @@ fun Context.defaultBrowserPackageName(): String? { * Returns a list of packages that support Custom Tabs. */ fun Context.getCustomTabsPackages(): ArrayList { - val pm = packageManager // Get default VIEW intent handler. - val activityIntent = Intent(Intent.ACTION_VIEW, "http://www.example.com".toUri()) + val activityIntent = Intent(Intent.ACTION_VIEW, "https://www.example.com".toUri()) // Get all apps that can handle VIEW intents. - val resolvedActivityList = pm.queryIntentActivities(activityIntent, 0) + val resolvedActivityList = packageManager.queryIntentActivitiesCompat(activityIntent, 0) val packagesSupportingCustomTabs = ArrayList() for (info in resolvedActivityList) { val serviceIntent = Intent() serviceIntent.action = ACTION_CUSTOM_TABS_CONNECTION serviceIntent.setPackage(info.activityInfo.packageName) // Check if this package also resolves the Custom Tabs service. - if (pm.resolveService(serviceIntent, 0) != null) { + if (packageManager.resolveServiceCompat(serviceIntent, 0) != null) { packagesSupportingCustomTabs.add(info) } } @@ -516,6 +441,17 @@ fun setLocaleByAppCompat() { } } +suspend fun CoroutineWorker.tryToSetForeground() { + try { + setForeground(getForegroundInfo()) + } catch (e: IllegalStateException) { + Timber.e(e, "Not allowed to set foreground job") + } +} + +fun WorkManager.jobIsRunning(tag: String): Boolean = getWorkInfosForUniqueWork(tag).get() + .let { list -> list.count { it.state == WorkInfo.State.RUNNING } == 1 } + val Context.systemLangContext: Context get() { val configuration = Configuration(resources.configuration) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/PackageManagerExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/PackageManagerExtensions.kt new file mode 100644 index 0000000000..87b78b314d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/PackageManagerExtensions.kt @@ -0,0 +1,39 @@ +package eu.kanade.tachiyomi.util.system + +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.os.Build + +fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): ApplicationInfo = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(flags.toLong())) + } else { + @Suppress("DEPRECATION") + getApplicationInfo(packageName, flags) + } + +fun PackageManager.resolveActivityCompat(intent: Intent, flags: Int): ResolveInfo? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + resolveActivity(intent, PackageManager.ResolveInfoFlags.of(flags.toLong())) + } else { + @Suppress("DEPRECATION") + resolveActivity(intent, flags) + } + +fun PackageManager.resolveServiceCompat(intent: Intent, flags: Int): ResolveInfo? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + resolveService(intent, PackageManager.ResolveInfoFlags.of(flags.toLong())) + } else { + @Suppress("DEPRECATION") + resolveService(intent, flags) + } + +fun PackageManager.queryIntentActivitiesCompat(intent: Intent, flags: Int): List = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(flags.toLong())) + } else { + @Suppress("DEPRECATION") + queryIntentActivities(intent, flags) + } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6ab4a15c77..4e24dd02a3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -195,6 +195,7 @@ Shift double pages + Allowing notifications is recommended to keep your library and app up to date. New chapters found Large updates may lead to increased battery usage and sources becoming slower. Tap to learn more. Warning: large bulk downloads may lead to sources becoming slower and/or blocking Tachiyomi. Tap to learn more. diff --git a/build.gradle.kts b/build.gradle.kts index c42c216c29..efc4d8df69 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,7 +25,7 @@ subprojects { buildscript { dependencies { - classpath("com.android.tools.build:gradle:7.4.2") + classpath("com.android.tools.build:gradle:8.1.0") classpath("com.google.gms:google-services:4.3.15") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${AndroidVersions.kotlin}") classpath("com.google.android.gms:oss-licenses-plugin:0.10.6") diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index d02b5b03ad..1072bc8671 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -3,9 +3,9 @@ import java.util.Locale object AndroidVersions { const val compileSdk = 33 const val minSdk = 23 - const val targetSdk = 30 - const val versionCode = 104 - const val versionName = "1.6.6" + const val targetSdk = 33 + const val versionCode = 105 + const val versionName = "1.7.0" const val ndk = "23.1.7779620" const val kotlin = "1.8.10" } diff --git a/gradle.properties b/gradle.properties index d47bc6cefc..146a70167a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,4 +21,7 @@ android.enableJetifier=true android.useAndroidX=true org.gradle.jvmargs=-Xmx2048M -org.gradle.caching=true \ No newline at end of file +org.gradle.caching=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b89f5c1c49..46c5b45850 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip