package eu.kanade.tachiyomi.extension import android.content.Context import android.graphics.drawable.Drawable import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import eu.kanade.domain.extension.interactor.TrustExtension import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.extension.api.ExtensionApi import eu.kanade.tachiyomi.extension.api.ExtensionUpdateNotifier import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.InstallStep 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.util.system.toast import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import logcat.LogPriority import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.source.model.StubSource import tachiyomi.i18n.MR import java.util.Locale /** * The manager of extensions installed as another apk which extend the available sources. It handles * the retrieval of remotely available extensions as well as installing, updating and removing them. * To avoid malicious distribution, every extension must be signed and it will only be loaded if its * signature is trusted, otherwise the user will be prompted with a warning to trust it before being * loaded. */ @Inject @SingleIn(AppScope::class) class ExtensionManager( private val context: Context, private val preferences: SourcePreferences, private val trustExtension: TrustExtension, private val api: ExtensionApi, private val installer: ExtensionInstaller, private val extensionUpdateNotifier: ExtensionUpdateNotifier, ) { val scope = CoroutineScope(SupervisorJob()) private val initialized = CompletableDeferred() private val iconMap = mutableMapOf() private val loadedExtensionMapFlow = MutableStateFlow(emptyMap()) val loadedExtensionsFlow = loadedExtensionMapFlow.mapExtensionsWhenInitialized() private val availableExtensionMapFlow = MutableStateFlow(emptyMap()) val availableExtensionsFlow = availableExtensionMapFlow.mapExtensions(scope) private val notLoadedExtensionMapFlow = MutableStateFlow(emptyMap()) val notLoadedExtensionsFlow = notLoadedExtensionMapFlow.mapExtensionsWhenInitialized() init { scope.launch(Dispatchers.IO) { loadExtensions() ExtensionInstallReceiver(InstallationListener()).register(context) // Everything the load decision rests on can change while running, so decide again merge( trustExtension.changes(), preferences.enabledContentWarnings.changes().distinctUntilChanged().drop(1).map {}, preferences.applyContentWarningsToInstalled.changes().distinctUntilChanged().drop(1).map {}, ) .collectLatest { loadExtensions() } } } private var subLanguagesEnabledOnFirstRun = preferences.enabledLanguages.isSet() suspend fun getLoadedExtensions(): List { initialized.await() return loadedExtensionMapFlow.value.values.toList() } suspend fun getNotLoadedExtensions(): List { initialized.await() return notLoadedExtensionMapFlow.value.values.toList() } suspend fun getExtensionPackage(sourceId: Long): String? { return getLoadedExtensions().find { extension -> extension.sources.any { it.id == sourceId } } ?.pkgName } fun getExtensionPackageAsFlow(sourceId: Long): Flow { return loadedExtensionsFlow.map { extensions -> extensions.find { extension -> extension.sources.any { it.id == sourceId } } ?.pkgName } } suspend fun getAppIconForSource(sourceId: Long): Drawable? { val pkgName = getExtensionPackage(sourceId) ?: return null return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo!! .loadIcon(context.packageManager) } } private var availableExtensionsSourcesData: Map = emptyMap() private fun setupAvailableExtensionsSourcesDataMap(extensions: List) { if (extensions.isEmpty()) return availableExtensionsSourcesData = extensions .flatMap { ext -> ext.sources.map { it.toStubSource() } } .associateBy { it.id } } fun getSourceData(id: Long) = availableExtensionsSourcesData[id] /** * Loads and registers the installed extensions. Safe to call again: every extension is judged * again, so one can move between loaded and not loaded in either direction, while extensions * that still pass keep the instances they already had. */ private suspend fun loadExtensions() { try { val extensions = ExtensionLoader.loadExtensions(context, loadedExtensionMapFlow.value) loadedExtensionMapFlow.value = extensions .filterIsInstance() .associateBy { it.pkgName } notLoadedExtensionMapFlow.value = extensions .filterIsInstance() .associateBy { it.pkgName } // Newly loaded extensions have no status derived from the store index yet updatedInstalledExtensionsStatuses(availableExtensionMapFlow.value.values.toList()) } catch (e: Throwable) { logcat(LogPriority.ERROR, e) { "Failed to load extensions" } } finally { // Release anything waiting on the extensions whether or not the load worked initialized.complete(Unit) } } /** * Finds the available extensions in the [api] and updates [availableExtensionMapFlow]. */ suspend fun findAvailableExtensions() { val extensions: List = try { api.findExtensions() } catch (e: Exception) { logcat(LogPriority.ERROR, e) withUIContext { context.toast(MR.strings.extension_api_error) } return } enableAdditionalSubLanguages(extensions) availableExtensionMapFlow.value = extensions.associateBy { it.pkgName } updatedInstalledExtensionsStatuses(extensions) setupAvailableExtensionsSourcesDataMap(extensions) } /** * Enables the additional sub-languages in the app first run. This addresses * the issue where users still need to enable some specific languages even when * the device language is inside that major group. As an example, if a user * has a zh device language, the app will also enable zh-Hans and zh-Hant. * * If the user have already changed the enabledLanguages preference value once, * the new languages will not be added to respect the user enabled choices. */ private fun enableAdditionalSubLanguages(extensions: List) { if (subLanguagesEnabledOnFirstRun || extensions.isEmpty()) { return } // Use the source lang as some aren't present on the extension level. val availableLanguages = extensions .flatMap(Extension.Available::sources) .distinctBy(Extension.Available.Source::lang) .map(Extension.Available.Source::lang) val deviceLanguage = Locale.getDefault().language val defaultLanguages = preferences.enabledLanguages.defaultValue() val languagesToEnable = availableLanguages.filter { it != deviceLanguage && it.startsWith(deviceLanguage) } preferences.enabledLanguages.set(defaultLanguages + languagesToEnable) subLanguagesEnabledOnFirstRun = true } /** * Sets the update field of the installed extensions with the given [availableExtensions]. * * @param availableExtensions The list of extensions given by the [api]. */ private fun updatedInstalledExtensionsStatuses(availableExtensions: List) { if (availableExtensions.isEmpty()) { preferences.extensionUpdatesCount.set(0) return } val loadedExtensionsMap = loadedExtensionMapFlow.value.toMutableMap() var changed = false for ((pkgName, extension) in loadedExtensionsMap) { val availableExt = availableExtensions.find { it.pkgName == pkgName } if (availableExt == null && !extension.isObsolete) { loadedExtensionsMap[pkgName] = extension.copy(isObsolete = true) changed = true } else if (availableExt != null) { val hasUpdate = extension.updateExists(availableExt) if (extension.hasUpdate != hasUpdate) { loadedExtensionsMap[pkgName] = extension.copy( hasUpdate = hasUpdate, store = availableExt.store, ) } else { loadedExtensionsMap[pkgName] = extension.copy( store = availableExt.store, ) } changed = true } } if (changed) { loadedExtensionMapFlow.value = loadedExtensionsMap } updatePendingUpdatesCount() } /** * Returns a flow of the installation process for the given extension. It will complete * once the extension is installed or throws an error. The process will be canceled if * unsubscribed before its completion. * * @param extension The extension to be installed. */ fun installExtension(extension: Extension.Available): Flow { return installer.downloadAndInstall(extension.apkUrl, extension) } /** * Returns a flow of the installation process for the given extension. It will complete * once the extension is updated or throws an error. The process will be canceled if * unsubscribed before its completion. * * @param extension The extension to be updated. */ fun updateExtension(extension: Extension.Loaded): Flow { val availableExt = availableExtensionMapFlow.value[extension.pkgName] ?: return emptyFlow() val isUpdateForPrivatelyInstalled = !extension.isShared return installer.downloadAndInstall(availableExt.apkUrl, availableExt, isUpdateForPrivatelyInstalled) } fun cancelInstallUpdateExtension(extension: Extension) { installer.cancelInstall(extension.pkgName) } /** * Sets to "installing" status of an extension installation. * * @param downloadId The id of the download. */ fun setInstalling(downloadId: Long) { installer.updateInstallStep(downloadId, InstallStep.Installing) } fun updateInstallStep(downloadId: Long, step: InstallStep) { installer.updateInstallStep(downloadId, step) } /** * Uninstalls the extension that matches the given package name. * * @param extension The extension to uninstall. */ fun uninstallExtension(extension: Extension.Installed) { installer.uninstallApk(extension.pkgName) } /** * Adds the given extension to the list of trusted extensions. It also loads in background the * now trusted extensions. * * @param extension the extension to trust */ fun trust(extension: Extension.NotLoaded) { val reason = extension.reason as? Extension.NotLoaded.Reason.Untrusted ?: return notLoadedExtensionMapFlow.value[extension.pkgName] ?: return // Loading it again is left to the reload triggered by the trust change trustExtension.trust(extension.pkgName, extension.versionCode, reason.signatureHash) } /** * Registers the given extension in this and the source managers. * * @param extension The extension to be registered. */ private fun registerExtension(extension: Extension.Loaded) { loadedExtensionMapFlow.value += extension } /** * 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. * * @param pkgName The package name of the uninstalled application. */ private fun unregisterExtension(pkgName: String) { loadedExtensionMapFlow.value -= pkgName notLoadedExtensionMapFlow.value -= pkgName } /** * Listener which receives events of the extensions being installed, updated or removed. */ private inner class InstallationListener : ExtensionInstallReceiver.Listener { override fun onExtensionLoaded(extension: Extension.Loaded) { registerExtension(extension.withUpdateCheck()) notLoadedExtensionMapFlow.value -= extension.pkgName updatePendingUpdatesCount() } override fun onExtensionNotLoaded(extension: Extension.NotLoaded) { loadedExtensionMapFlow.value -= extension.pkgName notLoadedExtensionMapFlow.value += extension updatePendingUpdatesCount() } override fun onPackageUninstalled(pkgName: String) { ExtensionLoader.uninstallPrivateExtension(context, pkgName) unregisterExtension(pkgName) updatePendingUpdatesCount() } } /** * Extension method to set the update field of an installed extension. */ private fun Extension.Loaded.withUpdateCheck(): Extension.Loaded { return if (updateExists()) { copy(hasUpdate = true) } else { this } } private fun Extension.Loaded.updateExists(availableExtension: Extension.Available? = null): Boolean { val availableExt = availableExtension ?: availableExtensionMapFlow.value[pkgName] ?: return false return (availableExt.versionCode > versionCode || availableExt.libVersion > libVersion) } private fun updatePendingUpdatesCount() { val pendingUpdateCount = loadedExtensionMapFlow.value.values.count { it.hasUpdate } preferences.extensionUpdatesCount.set(pendingUpdateCount) if (pendingUpdateCount == 0) { extensionUpdateNotifier.dismiss() } } private operator fun Map.plus(extension: T) = plus(extension.pkgName to extension) private fun StateFlow>.mapExtensions(scope: CoroutineScope): StateFlow> { return map { it.values.toList() }.stateIn(scope, SharingStarted.Lazily, value.values.toList()) } /** * Extensions are loaded in the background, so this flow only starts emitting once that finished. */ private fun StateFlow>.mapExtensionsWhenInitialized(): Flow> { return onStart { initialized.await() }.map { it.values.toList() } } }