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 co.touchlab.kermit.Logger 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.encodeToString import kotlinx.serialization.json.Json import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import yokai.i18n.MR import java.lang.ref.WeakReference import kotlin.math.max class ExtensionInstallerJob(val context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { private val notifier = ExtensionInstallNotifier(context.localeContext) private val preferences: PreferencesHelper = Injekt.get() private var activeInstalls = mutableListOf() val extensionManager: ExtensionManager = Injekt.get() private var emitScope = CoroutineScope(Job() + Dispatchers.Default) private var job: Job? = null override suspend fun getForegroundInfo(): ForegroundInfo { val notification = notifier.progressNotificationBuilder.build() val id = Notifications.ID_EXTENSION_PROGRESS return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } else { ForegroundInfo(id, notification) } } override suspend fun doWork(): Result { tryToSetForeground() instance = WeakReference(this) context.notificationManager.cancel(Notifications.ID_UPDATES_TO_EXTS) val showUpdated = inputData.getInt(KEY_SHOW_UPDATED, -1) val showUpdatedNotification = showUpdated > -1 val reRunUpdateCheck = showUpdated > 0 if (!showUpdatedNotification && !preferences.hasPromptedBeforeUpdateAll().get()) { context.toast(MR.strings.some_extensions_may_prompt) preferences.hasPromptedBeforeUpdateAll().set(true) } val json = inputData.getString(KEY_EXTENSION) ?: return Result.failure() val infos = try { Json.decodeFromString>(json) } catch (e: Exception) { Logger.e(e) { "Cannot decode string" } null } ?: return Result.failure() val list = infos.filter { val installedExt = extensionManager.installedExtensionsFlow.value.find { installed -> installed.pkgName == it.pkgName } ?: return@filter false installedExt.versionCode < it.versionCode || installedExt.libVersion < it.libVersion } activeInstalls = list.map { it.pkgName }.toMutableList() emitScope.launch { list.forEach { extensionManager.setPending(it.pkgName) } } var installed = 0 val installedExtensions = mutableListOf() val requestSemaphore = Semaphore(3) coroutineScope { job = launchIO { list.map { extension -> async { requestSemaphore.withPermit { extensionManager.installExtension(extension, this) .collect { if (it.first.isCompleted()) { activeInstalls.remove(extension.pkgName) installedExtensions.add(extension) installed++ val prefCount = preferences.extensionUpdatesCount().get() preferences.extensionUpdatesCount() .set(max(prefCount - 1, 0)) } notifier.showProgressNotification(installed, list.size) if (activeInstalls.isEmpty() || isStopped) { cancel() } } } } }.awaitAll() } } if (showUpdatedNotification && installedExtensions.size > 0) { notifier.showUpdatedNotification(installedExtensions, preferences.hideNotificationContent().get()) } if (reRunUpdateCheck || installedExtensions.size != list.size) { ExtensionUpdateJob.runJobAgain(context, NetworkType.CONNECTED, false) } activeInstalls.forEach { extensionManager.cleanUpInstallation(it) } activeInstalls.clear() val hasChain = withContext(Dispatchers.IO) { WorkManager.getInstance(context).getWorkInfosByTag(TAG).get().any { it.state == WorkInfo.State.BLOCKED } } if (!hasChain) { extensionManager.emitToInstaller("Finished", (InstallStep.Installed to null)) } if (instance?.get() == this) { instance = null } if (!hasChain) { context.notificationManager.cancel(Notifications.ID_EXTENSION_PROGRESS) } return Result.success() } companion object { private const val TAG = "ExtensionInstaller" /** * Key that defines what should be updated. */ const val KEY_EXTENSION = "extension" const val KEY_SHOW_UPDATED = "show_updated" private var instance: WeakReference? = null fun start(context: Context, extensions: List, showUpdatedExtension: Int = -1) { startJob(context, extensions.map(ExtensionManager::ExtensionInfo), showUpdatedExtension) } fun startJob(context: Context, info: List, showUpdatedExtension: Int = -1) { // chunked to satisfy input limits val requests = info.chunked(32).map { OneTimeWorkRequestBuilder() .addTag(TAG) .setInputData( workDataOf( KEY_EXTENSION to Json.encodeToString(it.toTypedArray()), KEY_SHOW_UPDATED to showUpdatedExtension, ), ) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() } var workContinuation = WorkManager.getInstance(context) .beginUniqueWork(TAG, ExistingWorkPolicy.REPLACE, requests.first()) for (i in 1 until requests.size) { workContinuation = workContinuation.then(requests[i]) } workContinuation.enqueue() } fun activeInstalls(): List? = instance?.get()?.activeInstalls fun removeActiveInstall(pkgName: String) = instance?.get()?.activeInstalls?.remove(pkgName) fun stop(context: Context) { instance?.get()?.job?.cancel() WorkManager.getInstance(context).cancelAllWorkByTag(TAG) } fun isRunning(context: Context) = WorkManager.getInstance(context).jobIsRunning(TAG) } }