mirror of
https://github.com/null2264/yokai.git
synced 2025-06-21 02:34:39 +00:00
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:
parent
21a2705c72
commit
3140361452
60 changed files with 2133 additions and 2348 deletions
|
@ -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")
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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>) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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>()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 -> {
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -122,4 +122,6 @@ object DiskUtil {
|
|||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
const val NOMEDIA_FILE = ".nomedia"
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue