Reload extensions when relevant settings are updated (#3954)
This commit is contained in:
@@ -4,6 +4,11 @@ import android.content.pm.PackageInfo
|
|||||||
import androidx.core.content.pm.PackageInfoCompat
|
import androidx.core.content.pm.PackageInfoCompat
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import eu.kanade.domain.source.service.SourcePreferences
|
import eu.kanade.domain.source.service.SourcePreferences
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.merge
|
||||||
import mihon.domain.extension.repository.ExtensionStoreRepository
|
import mihon.domain.extension.repository.ExtensionStoreRepository
|
||||||
import tachiyomi.core.common.preference.getAndSet
|
import tachiyomi.core.common.preference.getAndSet
|
||||||
|
|
||||||
@@ -31,4 +36,23 @@ class TrustExtension(
|
|||||||
fun revokeAll() {
|
fun revokeAll() {
|
||||||
preferences.trustedExtensions.delete()
|
preferences.trustedExtensions.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits whenever what counts as trusted changes, either because a store was added or removed or
|
||||||
|
* because an extension was trusted or had its trust revoked. Both sources replay their current
|
||||||
|
* value, which is dropped.
|
||||||
|
*/
|
||||||
|
fun changes(): Flow<Unit> {
|
||||||
|
return merge(
|
||||||
|
// Stores are rewritten whenever their index is refreshed, so only their keys matter here
|
||||||
|
repository.getAllAsFlow()
|
||||||
|
.map { stores -> stores.mapTo(HashSet()) { it.signingKey } }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.drop(1),
|
||||||
|
preferences.trustedExtensions.changes()
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.drop(1),
|
||||||
|
)
|
||||||
|
.map {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-4
@@ -400,10 +400,7 @@ object SettingsAdvancedScreen : SearchableSettings {
|
|||||||
),
|
),
|
||||||
Preference.PreferenceItem.TextPreference(
|
Preference.PreferenceItem.TextPreference(
|
||||||
title = stringResource(MR.strings.ext_revoke_trust),
|
title = stringResource(MR.strings.ext_revoke_trust),
|
||||||
onClick = {
|
onClick = { trustExtension.revokeAll() },
|
||||||
trustExtension.revokeAll()
|
|
||||||
context.toast(MR.strings.requires_app_restart)
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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.emptyFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -70,8 +74,16 @@ class ExtensionManager(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
initExtensions()
|
loadExtensions()
|
||||||
ExtensionInstallReceiver(InstallationListener()).register(context)
|
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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,11 +136,13 @@ class ExtensionManager(
|
|||||||
fun getSourceData(id: Long) = availableExtensionsSourcesData[id]
|
fun getSourceData(id: Long) = availableExtensionsSourcesData[id]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads and registers the installed extensions.
|
* 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 fun initExtensions() {
|
private suspend fun loadExtensions() {
|
||||||
try {
|
try {
|
||||||
val extensions = ExtensionLoader.loadExtensions(context)
|
val extensions = ExtensionLoader.loadExtensions(context, loadedExtensionMapFlow.value)
|
||||||
|
|
||||||
loadedExtensionMapFlow.value = extensions
|
loadedExtensionMapFlow.value = extensions
|
||||||
.filterIsInstance<Extension.Loaded>()
|
.filterIsInstance<Extension.Loaded>()
|
||||||
@@ -138,11 +152,13 @@ class ExtensionManager(
|
|||||||
.filterIsInstance<Extension.NotLoaded>()
|
.filterIsInstance<Extension.NotLoaded>()
|
||||||
.associateBy { it.pkgName }
|
.associateBy { it.pkgName }
|
||||||
|
|
||||||
initialized.complete(Unit)
|
// Newly loaded extensions have no status derived from the store index yet
|
||||||
|
updatedInstalledExtensionsStatuses(availableExtensionMapFlow.value.values.toList())
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
// Release anything waiting on the extensions before the failure propagates
|
logcat(LogPriority.ERROR, e) { "Failed to load extensions" }
|
||||||
|
} finally {
|
||||||
|
// Release anything waiting on the extensions whether or not the load worked
|
||||||
initialized.complete(Unit)
|
initialized.complete(Unit)
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,19 +307,12 @@ class ExtensionManager(
|
|||||||
*
|
*
|
||||||
* @param extension the extension to trust
|
* @param extension the extension to trust
|
||||||
*/
|
*/
|
||||||
suspend fun trust(extension: Extension.NotLoaded) {
|
fun trust(extension: Extension.NotLoaded) {
|
||||||
val reason = extension.reason as? Extension.NotLoaded.Reason.Untrusted ?: return
|
val reason = extension.reason as? Extension.NotLoaded.Reason.Untrusted ?: return
|
||||||
notLoadedExtensionMapFlow.value[extension.pkgName] ?: 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)
|
trustExtension.trust(extension.pkgName, extension.versionCode, reason.signatureHash)
|
||||||
|
|
||||||
notLoadedExtensionMapFlow.value -= extension.pkgName
|
|
||||||
|
|
||||||
when (val reloaded = ExtensionLoader.loadExtensionFromPkgName(context, extension.pkgName)) {
|
|
||||||
is Extension.Loaded -> registerExtension(reloaded)
|
|
||||||
is Extension.NotLoaded -> notLoadedExtensionMapFlow.value += reloaded
|
|
||||||
null -> {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
package eu.kanade.tachiyomi.extension.api
|
package eu.kanade.tachiyomi.extension.api
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import dev.zacsweers.metro.SingleIn
|
import dev.zacsweers.metro.SingleIn
|
||||||
import eu.kanade.tachiyomi.extension.model.Extension
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
|
|
||||||
import mihon.domain.extension.interactor.UpdateExtensionStores
|
import mihon.domain.extension.interactor.UpdateExtensionStores
|
||||||
import mihon.domain.extension.repository.ExtensionStoreRepository
|
import mihon.domain.extension.repository.ExtensionStoreRepository
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
import tachiyomi.core.common.util.lang.withIOContext
|
||||||
@@ -22,14 +20,15 @@ class ExtensionApi(
|
|||||||
return withIOContext { repository.fetchExtensions() }
|
return withIOContext { repository.fetchExtensions() }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun checkForUpdates(context: Context) {
|
/**
|
||||||
|
* @param loadedExtensions Extensions already loaded by [eu.kanade.tachiyomi.extension.ExtensionManager].
|
||||||
|
* Only their versions are read, so there's nothing to gain from loading them a second time.
|
||||||
|
*/
|
||||||
|
suspend fun checkForUpdates(loadedExtensions: List<Extension.Loaded>) {
|
||||||
updateExtensionStores()
|
updateExtensionStores()
|
||||||
|
|
||||||
val extensions = findExtensions()
|
val extensions = findExtensions()
|
||||||
|
|
||||||
val loadedExtensions = ExtensionLoader.loadExtensions(context)
|
|
||||||
.filterIsInstance<Extension.Loaded>()
|
|
||||||
|
|
||||||
val extensionsWithUpdate = mutableListOf<Extension.Loaded>()
|
val extensionsWithUpdate = mutableListOf<Extension.Loaded>()
|
||||||
for (installedExt in loadedExtensions) {
|
for (installedExt in loadedExtensions) {
|
||||||
val pkgName = installedExt.pkgName
|
val pkgName = installedExt.pkgName
|
||||||
|
|||||||
@@ -12,14 +12,13 @@ import eu.kanade.tachiyomi.source.Source
|
|||||||
import eu.kanade.tachiyomi.source.SourceFactory
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
import eu.kanade.tachiyomi.util.lang.Hash
|
import eu.kanade.tachiyomi.util.lang.Hash
|
||||||
import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo
|
import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
import mihon.app.di.appGraph
|
import mihon.app.di.appGraph
|
||||||
import mihon.data.dalvik.DelegateLastClassLoaderCompat
|
import mihon.data.dalvik.DelegateLastClassLoaderCompat
|
||||||
import mihon.domain.extension.model.ContentWarning
|
import mihon.domain.extension.model.ContentWarning
|
||||||
|
import tachiyomi.core.common.util.lang.withIOContext
|
||||||
import tachiyomi.core.common.util.system.logcat
|
import tachiyomi.core.common.util.system.logcat
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -110,8 +109,19 @@ internal object ExtensionLoader {
|
|||||||
* Return a list of all the available extensions initialized concurrently.
|
* Return a list of all the available extensions initialized concurrently.
|
||||||
*
|
*
|
||||||
* @param context The application context.
|
* @param context The application context.
|
||||||
|
* @param alreadyLoaded Extensions loaded by an earlier call. Any of these whose apk is unchanged
|
||||||
|
* and which still passes every check is returned as is, so its sources keep working and its
|
||||||
|
* update status survives. Pass nothing to load every extension from scratch.
|
||||||
*/
|
*/
|
||||||
fun loadExtensions(context: Context): List<Extension.Installed> {
|
suspend fun loadExtensions(
|
||||||
|
context: Context,
|
||||||
|
alreadyLoaded: Map<String, Extension.Loaded> = emptyMap(),
|
||||||
|
): List<Extension.Installed> {
|
||||||
|
val trustExtension = context.appGraph.trustExtension
|
||||||
|
val sourcePreferences = context.appGraph.sourcePreferences
|
||||||
|
val enabledContentWarnings = sourcePreferences.enabledContentWarnings.get()
|
||||||
|
val applyContentWarningsToInstalled = sourcePreferences.applyContentWarningsToInstalled.get()
|
||||||
|
|
||||||
val pkgManager = context.packageManager
|
val pkgManager = context.packageManager
|
||||||
|
|
||||||
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
@@ -157,11 +167,21 @@ internal object ExtensionLoader {
|
|||||||
if (extPkgs.isEmpty()) return emptyList()
|
if (extPkgs.isEmpty()) return emptyList()
|
||||||
|
|
||||||
// Load each extension concurrently and wait for completion
|
// Load each extension concurrently and wait for completion
|
||||||
return runBlocking(Dispatchers.IO) {
|
return withIOContext {
|
||||||
val deferred = extPkgs.map {
|
extPkgs
|
||||||
async { loadExtensionCatching(context, it) }
|
.map {
|
||||||
|
async {
|
||||||
|
loadExtensionCatching(
|
||||||
|
context = context,
|
||||||
|
extensionInfo = it,
|
||||||
|
trustExtension = trustExtension,
|
||||||
|
enabledContentWarnings = enabledContentWarnings,
|
||||||
|
applyContentWarningsToInstalled = applyContentWarningsToInstalled,
|
||||||
|
alreadyLoaded = alreadyLoaded[it.packageInfo.packageName],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
deferred.awaitAll()
|
}
|
||||||
|
.awaitAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +195,15 @@ internal object ExtensionLoader {
|
|||||||
logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" }
|
logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" }
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return loadExtensionCatching(context, extensionPackage)
|
|
||||||
|
val sourcePreferences = context.appGraph.sourcePreferences
|
||||||
|
return loadExtensionCatching(
|
||||||
|
context = context,
|
||||||
|
extensionInfo = extensionPackage,
|
||||||
|
trustExtension = context.appGraph.trustExtension,
|
||||||
|
enabledContentWarnings = sourcePreferences.enabledContentWarnings.get(),
|
||||||
|
applyContentWarningsToInstalled = sourcePreferences.applyContentWarningsToInstalled.get(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getExtensionPackageInfoFromPkgName(context: Context, pkgName: String): PackageInfo? {
|
fun getExtensionPackageInfoFromPkgName(context: Context, pkgName: String): PackageInfo? {
|
||||||
@@ -219,9 +247,23 @@ internal object ExtensionLoader {
|
|||||||
* ways it doesn't check for. Keep anything unforeseen to the extension that caused it instead of
|
* ways it doesn't check for. Keep anything unforeseen to the extension that caused it instead of
|
||||||
* letting it take down the load of every other extension.
|
* letting it take down the load of every other extension.
|
||||||
*/
|
*/
|
||||||
private suspend fun loadExtensionCatching(context: Context, extensionInfo: ExtensionInfo): Extension.Installed {
|
private suspend fun loadExtensionCatching(
|
||||||
|
context: Context,
|
||||||
|
extensionInfo: ExtensionInfo,
|
||||||
|
trustExtension: TrustExtension,
|
||||||
|
enabledContentWarnings: Set<ContentWarning>,
|
||||||
|
applyContentWarningsToInstalled: Boolean,
|
||||||
|
alreadyLoaded: Extension.Loaded? = null,
|
||||||
|
): Extension.Installed {
|
||||||
return try {
|
return try {
|
||||||
loadExtension(context, extensionInfo)
|
loadExtension(
|
||||||
|
context = context,
|
||||||
|
extensionInfo = extensionInfo,
|
||||||
|
trustExtension = trustExtension,
|
||||||
|
enabledContentWarnings = enabledContentWarnings,
|
||||||
|
applyContentWarningsToInstalled = applyContentWarningsToInstalled,
|
||||||
|
alreadyLoaded = alreadyLoaded,
|
||||||
|
)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
val pkgInfo = extensionInfo.packageInfo
|
val pkgInfo = extensionInfo.packageInfo
|
||||||
logcat(LogPriority.ERROR, e) { "Extension load error: ${pkgInfo.packageName}" }
|
logcat(LogPriority.ERROR, e) { "Extension load error: ${pkgInfo.packageName}" }
|
||||||
@@ -243,12 +285,14 @@ internal object ExtensionLoader {
|
|||||||
* @param context The application context.
|
* @param context The application context.
|
||||||
* @param extensionInfo The extension to load.
|
* @param extensionInfo The extension to load.
|
||||||
*/
|
*/
|
||||||
private suspend fun loadExtension(context: Context, extensionInfo: ExtensionInfo): Extension.Installed {
|
private suspend fun loadExtension(
|
||||||
val trustExtension: TrustExtension = context.appGraph.trustExtension
|
context: Context,
|
||||||
val sourcePreferences = context.appGraph.sourcePreferences
|
extensionInfo: ExtensionInfo,
|
||||||
val enabledContentWarnings = sourcePreferences.enabledContentWarnings.get()
|
trustExtension: TrustExtension,
|
||||||
val applyContentWarningsToInstalled = sourcePreferences.applyContentWarningsToInstalled.get()
|
enabledContentWarnings: Set<ContentWarning>,
|
||||||
|
applyContentWarningsToInstalled: Boolean,
|
||||||
|
alreadyLoaded: Extension.Loaded? = null,
|
||||||
|
): Extension.Installed {
|
||||||
val pkgManager = context.packageManager
|
val pkgManager = context.packageManager
|
||||||
val pkgInfo = extensionInfo.packageInfo
|
val pkgInfo = extensionInfo.packageInfo
|
||||||
val appInfo = pkgInfo.applicationInfo
|
val appInfo = pkgInfo.applicationInfo
|
||||||
@@ -324,6 +368,15 @@ internal object ExtensionLoader {
|
|||||||
return notLoaded(Extension.NotLoaded.Reason.Filtered, libVersion)
|
return notLoaded(Extension.NotLoaded.Reason.Filtered, libVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Everything above is cheap to check again, everything below isn't. Nothing about this apk
|
||||||
|
// changed and it still passes, so keep the sources that are already registered for it.
|
||||||
|
if (alreadyLoaded != null &&
|
||||||
|
alreadyLoaded.versionCode == versionCode &&
|
||||||
|
alreadyLoaded.isShared == extensionInfo.isShared
|
||||||
|
) {
|
||||||
|
return alreadyLoaded
|
||||||
|
}
|
||||||
|
|
||||||
val classLoader = try {
|
val classLoader = try {
|
||||||
DelegateLastClassLoaderCompat(appInfo.sourceDir, null, context.classLoader)
|
DelegateLastClassLoaderCompat(appInfo.sourceDir, null, context.classLoader)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -224,10 +224,8 @@ class ExtensionsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun trustExtension(extension: Extension.NotLoaded) {
|
fun trustExtension(extension: Extension.NotLoaded) {
|
||||||
viewModelScope.launch {
|
|
||||||
extensionManager.trust(extension)
|
extensionManager.trust(extension)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class State(
|
data class State(
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ import eu.kanade.presentation.util.DefaultNavigatorScreenTransition
|
|||||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadCache
|
import eu.kanade.tachiyomi.data.download.DownloadCache
|
||||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||||
|
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||||
import eu.kanade.tachiyomi.extension.api.ExtensionApi
|
import eu.kanade.tachiyomi.extension.api.ExtensionApi
|
||||||
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
|
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
|
||||||
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
|
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
|
||||||
@@ -142,6 +143,8 @@ class MainActivity : BaseActivity() {
|
|||||||
|
|
||||||
@Inject private lateinit var extensionApi: ExtensionApi
|
@Inject private lateinit var extensionApi: ExtensionApi
|
||||||
|
|
||||||
|
@Inject private lateinit var extensionManager: ExtensionManager
|
||||||
|
|
||||||
// To be checked by splash screen. If true then splash screen will be removed.
|
// To be checked by splash screen. If true then splash screen will be removed.
|
||||||
var ready = false
|
var ready = false
|
||||||
|
|
||||||
@@ -339,7 +342,7 @@ class MainActivity : BaseActivity() {
|
|||||||
// Extensions updates
|
// Extensions updates
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
try {
|
try {
|
||||||
extensionApi.checkForUpdates(context)
|
extensionApi.checkForUpdates(extensionManager.getLoadedExtensions())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logcat(LogPriority.ERROR, e)
|
logcat(LogPriority.ERROR, e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,7 +270,7 @@
|
|||||||
<string name="pref_allowed_content_warnings">Allowed content warnings</string>
|
<string name="pref_allowed_content_warnings">Allowed content warnings</string>
|
||||||
<string name="pref_apply_content_warnings_to_installed">Also apply to installed extensions</string>
|
<string name="pref_apply_content_warnings_to_installed">Also apply to installed extensions</string>
|
||||||
<string name="pref_apply_content_warnings_to_installed_summary">When off, extensions you already installed keep loading and updating regardless of their warning</string>
|
<string name="pref_apply_content_warnings_to_installed_summary">When off, extensions you already installed keep loading and updating regardless of their warning</string>
|
||||||
<string name="content_warnings_info">Requires app restart to take effect. Installed extensions that aren\'t loaded stay listed without their sources; ones you haven\'t installed are hidden. This does not prevent unofficial or potentially incorrectly flagged extensions from surfacing 18+ content within the app.</string>
|
<string name="content_warnings_info">Installed extensions that aren\'t loaded stay listed without their sources; ones you haven\'t installed are hidden. This does not prevent unofficial or potentially incorrectly flagged extensions from surfacing 18+ content within the app.</string>
|
||||||
|
|
||||||
<string name="relative_time_today">Today</string>
|
<string name="relative_time_today">Today</string>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user