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>
This commit is contained in:
Jays2Kings 2023-07-31 16:19:43 -04:00 committed by GitHub
parent 21a2705c72
commit 3140361452
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 2133 additions and 2348 deletions

View file

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

View file

@ -241,24 +241,9 @@
</receiver>
<service
android:name=".data.library.LibraryUpdateService"
android:exported="false" />
<service
android:name=".extension.ExtensionInstallService"
android:exported="false" />
<service
android:name=".data.download.DownloadService"
android:exported="false" />
<service
android:name=".data.updater.AppUpdateService"
android:exported="false" />
<service
android:name=".data.backup.BackupRestoreService"
android:exported="false"/>
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"

View file

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi
import android.Manifest
import android.annotation.SuppressLint
import android.app.Application
import android.app.PendingIntent
@ -7,9 +8,11 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.webkit.WebView
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
@ -35,9 +38,7 @@ import kotlinx.coroutines.flow.onEach
import org.conscrypt.Conscrypt
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.InjektScope
import uy.kohesive.injekt.injectLazy
import uy.kohesive.injekt.registry.default.DefaultRegistrar
import java.security.Security
open class App : Application(), DefaultLifecycleObserver {
@ -62,7 +63,6 @@ open class App : Application(), DefaultLifecycleObserver {
if (packageName != process) WebView.setDataDirectorySuffix(process)
}
Injekt = InjektScope(DefaultRegistrar())
Injekt.importModule(AppModule(this))
CoilSetup(this)
@ -101,6 +101,13 @@ open class App : Application(), DefaultLifecycleObserver {
)
setContentIntent(pendingIntent)
}
if (ActivityCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
return@onEach
}
notificationManager.notify(Notifications.ID_INCOGNITO_MODE, notification)
} else {
disableIncognitoReceiver.unregister()

View file

@ -10,8 +10,8 @@ import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.plusAssign
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob
import eu.kanade.tachiyomi.data.updater.AppUpdateJob
import eu.kanade.tachiyomi.data.updater.AppUpdateService
import eu.kanade.tachiyomi.extension.ExtensionUpdateJob
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
import eu.kanade.tachiyomi.ui.library.LibraryPresenter
@ -37,7 +37,7 @@ object Migrations {
val context = preferences.context
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
prefs.edit {
remove(AppUpdateService.NOTIFY_ON_INSTALL_KEY)
remove(AppDownloadInstallJob.NOTIFY_ON_INSTALL_KEY)
}
val oldVersion = preferences.lastVersionCode().get()
if (oldVersion < BuildConfig.VERSION_CODE) {
@ -216,6 +216,10 @@ object Migrations {
preferences.groupChaptersHistory().set(RecentsPresenter.GroupType.Never)
}
}
if (oldVersion < 105) {
LibraryUpdateJob.cancelAllWorks(context)
LibraryUpdateJob.setupTask(context)
}
return true
}

View file

@ -12,7 +12,6 @@ import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
import kotlinx.coroutines.Job
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.text.SimpleDateFormat
@ -25,8 +24,6 @@ abstract class AbstractBackupRestore<T : AbstractBackupManager>(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

View file

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

View file

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

View file

@ -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) {
)
}
if (progress != -1) {
builder.show(Notifications.ID_RESTORE_PROGRESS)
}
return builder
}

View file

@ -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<BackupRestoreJob>()
.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)
}
}

View file

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

View file

@ -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<BackupManager>(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 }
return coroutineScope {
// Restore individual manga
backup.backupManga.forEach {
if (job?.isActive != true) {
return false
if (!isActive) {
return@coroutineScope false
}
restoreManga(it, backup.backupCategories)
}
true
}
// TODO: optionally trigger online library + tracker update
return true
}
private fun restoreCategories(backupCategories: List<BackupCategory>) {
@ -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)
}
/**

View file

@ -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<Boolean>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val downloadFlow = downloadChannel.asSharedFlow()
fun start(context: Context, alsoStartExtJob: Boolean = false) {
val request = OneTimeWorkRequestBuilder<DownloadJob>()
.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 }
}
}
}

View file

@ -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<Boolean>
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<Download>) {
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)

View file

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

View file

@ -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<Boolean> = BehaviorRelay.create(false)
private val listeners = mutableSetOf<DownloadServiceListener>()
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)
}

View file

@ -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<List<Download>>()
/**
* Relay to subscribe to the downloader status.
*/
val runningRelay: BehaviorRelay<Boolean> = 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()
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<Chapter>, 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<Download> = 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,56 +350,72 @@ 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 ->
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))
}
// Don't trust index from source
val reIndexedPages = pages.mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) }
val reIndexedPages = pages.mapIndexed { index, page ->
Page(
index,
page.url,
page.imageUrl,
page.uri,
)
}
download.pages = reIndexedPages
reIndexedPages
}
} 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
}
// 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()
pageList.asFlow()
.flatMapMerge(concurrency = 2) { page ->
flow {
withIOContext { getOrDownloadImage(page, download, tmpDir) }
emit(page)
}.flowOn(Dispatchers.IO)
}
.collect {
// Do when page is downloaded.
.doOnNext { notifier.onProgressChange(download) }
.toList()
.map { download }
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
}
}
@ -401,75 +427,80 @@ 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<Page> {
) {
// 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") }
// 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)
try {
// 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)
val file = when {
imageFile != null -> imageFile
chapterCache.isImageInCache(page.imageUrl!!) -> moveImageFromCache(
chapterCache.getImageFile(
page.imageUrl!!,
),
tmpDir,
filename,
)
else -> downloadImage(page, download.source, tmpDir, filename)
}
val chapName = download.chapter.preferredChapterName(context, download.manga, preferences)
return pageObservable
// 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)
if (!success) {
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
}
.map { page }
} 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
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> {
): UniFile {
page.status = Page.State.DOWNLOAD_IMAGE
page.progress = 0
return source.fetchImage(page)
.map { response ->
return flow {
val response = source.getImage(page)
val file = tmpDir.createFile("$filename.tmp")
try {
response.body.source().saveTo(file.openOutputStream())
@ -480,36 +511,38 @@ class Downloader(
file.delete()
throw e
}
file
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<UniFile> {
return Observable.just(cacheFile).map {
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
val extension = ImageUtil.findImageType(cacheFile.inputStream()) ?: return tmpFile
tmpFile.renameTo("$filename.${extension.extension}")
cacheFile.delete()
tmpFile
}
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.
*/

View file

@ -11,11 +11,11 @@ class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) {
var pages: List<Page>? = 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

View file

@ -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 {
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<Deferred<Any>>()
private val extraScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val emitScope = MainScope()
private val mangaToUpdate = mutableListOf<LibraryManga>()
private val mangaToUpdateMap = mutableMapOf<Long, List<LibraryManga>>()
private val categoryIds = mutableSetOf<Int>()
// List containing new updates
private val newUpdates = mutableMapOf<LibraryManga, Array<Chapter>>()
// List containing failed updates
private val failedUpdates = mutableMapOf<Manga, String?>()
// List containing skipped updates
private val skippedUpdates = mutableMapOf<Manga, String?>()
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<PreferencesHelper>()
return if (requiresWifiConnection(preferences) && !context.isConnectedToWifi()) {
Result.failure()
} else if (LibraryUpdateService.start(context)) {
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<LibraryManga>) {
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<LibraryManga>) {
// 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<LibraryManga>) = 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<LibraryManga>) {
// 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<Chapter>) {
// 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<LibraryManga>): List<LibraryManga> {
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<LibraryManga> {
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<LibraryManga> {
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<Manga, String?>, 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<LibraryManga>) {
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<LibraryManga>) {
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<LibraryUpdateJob>? = null
val updateMutableFlow = MutableSharedFlow<Long?>(
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<PreferencesHelper>()
@ -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<LibraryManga>? = 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<LibraryUpdateJob>()
.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)
}
}
}
}
}

View file

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

View file

@ -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<LibraryManga>()
private val mangaToUpdateMap = mutableMapOf<Long, List<LibraryManga>>()
private val categoryIds = mutableSetOf<Int>()
// List containing new updates
private val newUpdates = mutableMapOf<LibraryManga, Array<Chapter>>()
// List containing failed updates
private val failedUpdates = mutableMapOf<Manga, String?>()
// List containing skipped updates
private val skippedUpdates = mutableMapOf<Manga, String?>()
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<LibraryManga> {
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<LibraryManga> {
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<LibraryManga>, 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<LibraryManga>) {
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<LibraryManga>) {
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<LibraryManga>): List<LibraryManga> {
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<LibraryManga>) {
// 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<Chapter>) {
// 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<LibraryManga>) = 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<LibraryManga>) {
// 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<Manga, String?>, 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<LibraryManga>? = 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

View file

@ -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<ExtensionManager.ExtensionInfo> ?: return
ExtensionInstallerJob.startJob(context, extensions, 1)
}
/**
@ -195,12 +206,12 @@ class NotificationReceiver : BroadcastReceiver() {
private fun markAsRead(chapterUrls: Array<String>, 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<Extension.Available>): 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
*

View file

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

View file

@ -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<PreferencesHelper>()
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<AppDownloadInstallJob>? = 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<AppDownloadInstallJob>()
.addTag(TAG)
.apply {
if (waitUntilIdle) {
data.putBoolean(IDLE_RUN, true)
val shouldAutoUpdate = Injekt.get<PreferencesHelper>().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)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<PreferencesHelper>()
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<AutoAppUpdaterJob>()
.addTag(TAG)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request)
}
fun cancelTask(context: Context) {
WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
}
}
}

View file

@ -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<String>()
/**
* 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<ExtensionInfo>(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<ExtensionInfo>()
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<String>? = 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<Extension.Available>, 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)
}
}
}
}

View file

@ -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<String>()
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<Array<ExtensionManager.ExtensionInfo>>(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<ExtensionManager.ExtensionInfo>()
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<ExtensionInstallerJob>? = null
fun start(context: Context, extensions: List<Extension.Available>, showUpdatedExtension: Int = -1) {
startJob(context, extensions.map(ExtensionManager::ExtensionInfo), showUpdatedExtension)
}
fun startJob(context: Context, info: List<ExtensionManager.ExtensionInfo>, showUpdatedExtension: Int = -1) {
// chunked to satisfy input limits
val requests = info.chunked(32).map {
OneTimeWorkRequestBuilder<ExtensionInstallerJob>()
.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<String>? = 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)
}
}

View file

@ -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<String, Drawable>()
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<Extension.Installed>()
// 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<Long, Extension.AvailableSource>()
/**
* List of the currently available extensions.
*/
// var availableExtensions = emptyList<Extension.Available>()
// 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<Extension.Untrusted>())
val untrustedExtensionsFlow = _untrustedExtensionsFlow.asStateFlow()
/**
* List of the currently untrusted extensions.
*/
// var untrustedExtensions = emptyList<Extension.Untrusted>()
// 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,

View file

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

View file

@ -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<Pair<String, ExtensionIntallInfo>>()
val downloadSharedFlow = _downloadsSharedFlow.asSharedFlow()
/** Map of download id to installer session id */
val downloadInstallerMap = hashMapOf<String, Int>()
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<ExtensionIntallInfo> {
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
}

View file

@ -69,8 +69,9 @@ class DownloadBottomPresenter : BaseCoroutinePresenter<DownloadBottomSheet>() {
/**
* Clears the download queue.
*/
fun clearQueue() {
fun stopDownloads() {
downloadManager.clearQueue()
downloadManager.stopDownloads()
}
fun reorder(downloads: List<Download>) {

View file

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

View file

@ -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<InstallStep, PackageInstaller.SessionInfo?>
/**
* Presenter of [ExtensionBottomSheet].
*/
class ExtensionBottomPresenter() : BaseMigrationPresenter<ExtensionBottomSheet>() {
class ExtensionBottomPresenter : BaseMigrationPresenter<ExtensionBottomSheet>() {
private var extensions = emptyList<ExtensionItem>()
@ -52,11 +50,13 @@ class ExtensionBottomPresenter() : BaseMigrationPresenter<ExtensionBottomSheet>(
listOf(migrationJob, extensionJob).awaitAll()
}
presenterScope.launch {
extensionManager.downloadRelay.asSharedFlow()
extensionManager.downloadSharedFlow
.collect {
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<ExtensionBottomSheet>(
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<ExtensionBottomSheet>(
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) {

View file

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

View file

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

View file

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

View file

@ -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<MainActivityBinding>(), DownloadServiceListener {
open class MainActivity : BaseActivity<MainActivityBinding>() {
protected lateinit var router: Router
@ -171,6 +177,17 @@ open class MainActivity : BaseActivity<MainActivityBinding>(), 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<MainActivityBinding>(), 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<MainActivityBinding>(), 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<MainActivityBinding>(), DownloadServiceLi
checkForAppUpdates()
getExtensionUpdates(false)
setExtensionsBadge()
DownloadService.callListeners()
DownloadJob.callListeners(downloadManager = downloadManager)
showDLQueueTutorial()
reEnableBackPressedCallBack()
}
@ -809,6 +827,7 @@ open class MainActivity : BaseActivity<MainActivityBinding>(), 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<MainActivityBinding>(), 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<MainActivityBinding>(), 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<MainActivityBinding>(), DownloadServiceLi
}
}
override fun downloadStatusChanged(downloading: Boolean) {
private fun downloadStatusChanged(downloading: Boolean) {
lifecycleScope.launchUI {
val hasQueue = downloading || downloadManager.hasQueue()
launchUI {
if (hasQueue) {
nav.getOrCreateBadge(R.id.nav_recents)
showDLQueueTutorial()

View file

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

View file

@ -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<MangaDetailsController>(), DownloadQueue.DownloadListener, LibraryServiceListener {
) : BaseCoroutinePresenter<MangaDetailsController>(), 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<Application>()

View file

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

View file

@ -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<Snackbar>() {
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()

View file

@ -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<RecentsController>(), DownloadQueue.DownloadListener, LibraryServiceListener, DownloadServiceListener {
) : BaseCoroutinePresenter<RecentsController>(), DownloadQueue.DownloadListener {
private var recentsJob: Job? = null
var recentItems = listOf<RecentMangaItem>()
@ -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) {
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 -> {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -122,4 +122,6 @@ object DiskUtil {
else -> true
}
}
const val NOMEDIA_FILE = ".nomedia"
}

View file

@ -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<ResolveInfo> {
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<ResolveInfo>()
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)

View file

@ -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<ResolveInfo> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(flags.toLong()))
} else {
@Suppress("DEPRECATION")
queryIntentActivities(intent, flags)
}

View file

@ -195,6 +195,7 @@
<string name="shift_double_pages">Shift double pages</string>
<!-- Library update service notifications -->
<string name="allow_notifications_recommended">Allowing notifications is recommended to keep your library and app up to date.</string>
<string name="new_chapters_found">New chapters found</string>
<string name="notification_size_warning">Large updates may lead to increased battery usage and sources becoming slower. Tap to learn more.</string>
<string name="download_queue_size_warning">Warning: large bulk downloads may lead to sources becoming slower and/or blocking Tachiyomi. Tap to learn more.</string>

View file

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

View file

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

View file

@ -22,3 +22,6 @@ android.enableJetifier=true
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2048M
org.gradle.caching=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false

View file

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