[A12] Automatically update extensions that can be silently updated

This commit is contained in:
Jays2Kings 2021-08-07 00:10:22 -04:00
parent 51ec6d9bf6
commit 9f31529870
11 changed files with 177 additions and 17 deletions

View File

@ -47,11 +47,14 @@ object Notifications {
/** /**
* Notification channel and ids used by the library updater. * Notification channel and ids used by the library updater.
*/ */
private const val GROUP_EXTENSION_UPDATES = "group_extension_updates"
const val CHANNEL_UPDATES_TO_EXTS = "updates_ext_channel" const val CHANNEL_UPDATES_TO_EXTS = "updates_ext_channel"
const val ID_UPDATES_TO_EXTS = -401 const val ID_UPDATES_TO_EXTS = -401
const val CHANNEL_EXT_PROGRESS = "ext_update_progress_channel" const val CHANNEL_EXT_PROGRESS = "ext_update_progress_channel"
const val ID_EXTENSION_PROGRESS = -402 const val ID_EXTENSION_PROGRESS = -402
const val CHANNEL_EXT_UPDATED = "ext_updated_channel"
const val ID_UPDATED_EXTS = -403
private const val GROUP_BACKUP_RESTORE = "group_backup_restore" private const val GROUP_BACKUP_RESTORE = "group_backup_restore"
const val CHANNEL_BACKUP_RESTORE_PROGRESS = "backup_restore_progress_channel" const val CHANNEL_BACKUP_RESTORE_PROGRESS = "backup_restore_progress_channel"
@ -84,6 +87,7 @@ object Notifications {
listOf( listOf(
NotificationChannelGroup(GROUP_BACKUP_RESTORE, context.getString(R.string.backup_and_restore)), NotificationChannelGroup(GROUP_BACKUP_RESTORE, context.getString(R.string.backup_and_restore)),
NotificationChannelGroup(GROUP_EXTENSION_UPDATES, context.getString(R.string.extension_updates)),
).forEach(context.notificationManager::createNotificationChannelGroup) ).forEach(context.notificationManager::createNotificationChannelGroup)
val channels = listOf( val channels = listOf(
@ -108,9 +112,11 @@ object Notifications {
}, },
NotificationChannel( NotificationChannel(
CHANNEL_UPDATES_TO_EXTS, CHANNEL_UPDATES_TO_EXTS,
context.getString(R.string.extension_updates), context.getString(R.string.extension_updates_pending),
NotificationManager.IMPORTANCE_DEFAULT NotificationManager.IMPORTANCE_DEFAULT
), ).apply {
group = GROUP_EXTENSION_UPDATES
},
NotificationChannel( NotificationChannel(
CHANNEL_NEW_CHAPTERS, CHANNEL_NEW_CHAPTERS,
context.getString(R.string.new_chapters), context.getString(R.string.new_chapters),
@ -147,9 +153,17 @@ object Notifications {
context.getString(R.string.updating_extensions), context.getString(R.string.updating_extensions),
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW
).apply { ).apply {
group = GROUP_EXTENSION_UPDATES
setShowBadge(false) setShowBadge(false)
setSound(null, null) setSound(null, null)
}, },
NotificationChannel(
CHANNEL_EXT_UPDATED,
context.getString(R.string.extensions_updated),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
group = GROUP_EXTENSION_UPDATES
},
NotificationChannel( NotificationChannel(
CHANNEL_UPDATED, CHANNEL_UPDATED,
context.getString(R.string.update_completed), context.getString(R.string.update_completed),

View File

@ -231,6 +231,8 @@ object PreferenceKeys {
const val shouldAutoUpdate = "should_auto_update" const val shouldAutoUpdate = "should_auto_update"
const val autoUpdateExtensions = "auto_update_extensions"
const val defaultChapterFilterByRead = "default_chapter_filter_by_read" const val defaultChapterFilterByRead = "default_chapter_filter_by_read"
const val defaultChapterFilterByDownloaded = "default_chapter_filter_by_downloaded" const val defaultChapterFilterByDownloaded = "default_chapter_filter_by_downloaded"

View File

@ -431,6 +431,8 @@ class PreferencesHelper(val context: Context) {
fun shouldAutoUpdate() = prefs.getInt(Keys.shouldAutoUpdate, AutoUpdaterJob.ONLY_ON_UNMETERED) fun shouldAutoUpdate() = prefs.getInt(Keys.shouldAutoUpdate, AutoUpdaterJob.ONLY_ON_UNMETERED)
fun autoUpdateExtensions() = prefs.getInt(Keys.autoUpdateExtensions, AutoUpdaterJob.ONLY_ON_UNMETERED)
fun filterChapterByRead() = flowPrefs.getInt(Keys.defaultChapterFilterByRead, Manga.SHOW_ALL) fun filterChapterByRead() = flowPrefs.getInt(Keys.defaultChapterFilterByRead, Manga.SHOW_ALL)
fun filterChapterByDownloaded() = flowPrefs.getInt(Keys.defaultChapterFilterByDownloaded, Manga.SHOW_ALL) fun filterChapterByDownloaded() = flowPrefs.getInt(Keys.defaultChapterFilterByDownloaded, Manga.SHOW_ALL)

View File

@ -29,7 +29,7 @@ class ExtensionInstallNotifier(private val context: Context) {
* Cached progress notification to avoid creating a lot. * Cached progress notification to avoid creating a lot.
*/ */
val progressNotificationBuilder by lazy { val progressNotificationBuilder by lazy {
context.notificationBuilder(Notifications.CHANNEL_UPDATES_TO_EXTS) { context.notificationBuilder(Notifications.CHANNEL_EXT_PROGRESS) {
setContentTitle(context.getString(R.string.app_name)) setContentTitle(context.getString(R.string.app_name))
setSmallIcon(android.R.drawable.stat_sys_download) setSmallIcon(android.R.drawable.stat_sys_download)
setLargeIcon(notificationBitmap) setLargeIcon(notificationBitmap)
@ -54,9 +54,35 @@ class ExtensionInstallNotifier(private val context: Context) {
context.notificationManager.notify( context.notificationManager.notify(
Notifications.ID_EXTENSION_PROGRESS, Notifications.ID_EXTENSION_PROGRESS,
progressNotificationBuilder progressNotificationBuilder
.setChannelId(Notifications.CHANNEL_EXT_PROGRESS)
.setContentTitle(context.getString(R.string.updating_extensions)) .setContentTitle(context.getString(R.string.updating_extensions))
.setProgress(max, progress, progress == 0) .setProgress(max, progress, progress == 0)
.build() .build()
) )
} }
fun showUpdatedNotification(extensions: List<ExtensionManager.ExtensionInfo>, hideContent: Boolean) {
context.notificationManager.notify(
Notifications.ID_UPDATED_EXTS,
progressNotificationBuilder
.setChannelId(Notifications.CHANNEL_EXT_UPDATED)
.setContentTitle(
context.resources.getQuantityString(
R.plurals.extensions_updated_plural,
extensions.size,
extensions.size
)
)
.setSmallIcon(R.drawable.ic_extension_updated_24dp)
.setOngoing(false)
.setContentIntent(NotificationReceiver.openExtensionsPendingActivity(context))
.clearActions()
.setProgress(0, 0, false).apply {
if (!hideContent) {
setContentText(extensions.joinToString(", ") { it.name })
}
}
.build()
)
}
} }

View File

@ -5,9 +5,11 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import androidx.work.NetworkType
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.extension.ExtensionManager.ExtensionInfo import eu.kanade.tachiyomi.extension.ExtensionManager.ExtensionInfo
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.util.system.notificationManager import eu.kanade.tachiyomi.util.system.notificationManager
@ -26,6 +28,7 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.ArrayList import java.util.ArrayList
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.math.max
class ExtensionInstallService( class ExtensionInstallService(
val extensionManager: ExtensionManager = Injekt.get(), val extensionManager: ExtensionManager = Injekt.get(),
@ -63,7 +66,11 @@ class ExtensionInstallService(
*/ */
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) return START_NOT_STICKY if (intent == null) return START_NOT_STICKY
if (!preferences.hasPromptedBeforeUpdateAll().get()) { 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) toast(R.string.some_extensions_may_prompt)
preferences.hasPromptedBeforeUpdateAll().set(true) preferences.hasPromptedBeforeUpdateAll().set(true)
} }
@ -79,14 +86,19 @@ class ExtensionInstallService(
} }
?: return START_NOT_STICKY ?: return START_NOT_STICKY
var installed = 0 var installed = 0
val installedExtensions = mutableListOf<ExtensionInfo>()
job = serviceScope.launch { job = serviceScope.launch {
val results = list.map { val results = list.map { extension ->
async { async {
requestSemaphore.withPermit { requestSemaphore.withPermit {
extensionManager.installExtension(it, serviceScope) extensionManager.installExtension(extension, serviceScope)
.collect { .collect {
if (it.first.isCompleted()) { if (it.first.isCompleted()) {
installedExtensions.add(extension)
installed++ installed++
val prefCount =
preferences.extensionUpdatesCount().getOrDefault()
preferences.extensionUpdatesCount().set(max(prefCount - 1, 0))
} }
notifier.showProgressNotification(installed, list.size) notifier.showProgressNotification(installed, list.size)
} }
@ -95,9 +107,18 @@ class ExtensionInstallService(
} }
results.awaitAll() results.awaitAll()
} }
job?.invokeOnCompletion { stopSelf(startId) }
return START_REDELIVER_INTENT job?.invokeOnCompletion {
if (showUpdatedNotification) {
notifier.showUpdatedNotification(installedExtensions, preferences.hideNotificationContent())
}
if (reRunUpdateCheck || installedExtensions.size != list.size) {
ExtensionUpdateJob.runJobAgain(this, NetworkType.CONNECTED)
}
stopSelf(startId)
}
return START_NOT_STICKY
} }
/** /**
@ -149,11 +170,13 @@ class ExtensionInstallService(
* Key that defines what should be updated. * Key that defines what should be updated.
*/ */
private const val KEY_EXTENSION = "extension" private const val KEY_EXTENSION = "extension"
private const val KEY_SHOW_UPDATED = "show_updated"
fun jobIntent(context: Context, extensions: List<Extension.Available>): Intent { fun jobIntent(context: Context, extensions: List<Extension.Available>, showUpdatedExtension: Int = 0): Intent {
return Intent(context, ExtensionInstallService::class.java).apply { return Intent(context, ExtensionInstallService::class.java).apply {
val info = extensions.map(::ExtensionInfo) val info = extensions.map(::ExtensionInfo)
putParcelableArrayListExtra(KEY_EXTENSION, ArrayList(info)) putParcelableArrayListExtra(KEY_EXTENSION, ArrayList(info))
putExtra(KEY_SHOW_UPDATED, showUpdatedExtension)
} }
} }
} }

View File

@ -182,6 +182,10 @@ class ExtensionManager(
return untrustedExtensionsRelay.asObservable() return untrustedExtensionsRelay.asObservable()
} }
fun isInstalledByApp(extension: Extension.Available): Boolean {
return ExtensionLoader.isExtensionInstalledByApp(context, extension.pkgName)
}
/** /**
* Finds the available extensions in the [api] and updates [availableExtensions]. * Finds the available extensions in the [api] and updates [availableExtensions].
*/ */

View File

@ -9,7 +9,9 @@ import androidx.core.content.ContextCompat
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
@ -18,8 +20,10 @@ import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.updater.AutoUpdaterJob
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.util.system.connectivityManager
import eu.kanade.tachiyomi.util.system.notification import eu.kanade.tachiyomi.util.system.notification
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -44,15 +48,40 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
Result.success() Result.success()
} }
private fun createUpdateNotification(extensions: List<Extension.Available>) { private fun createUpdateNotification(extensionsList: List<Extension.Available>) {
val extensions = extensionsList.toMutableList()
val preferences: PreferencesHelper by injectLazy() val preferences: PreferencesHelper by injectLazy()
preferences.extensionUpdatesCount().set(extensions.size) preferences.extensionUpdatesCount().set(extensions.size)
// Not doing this yet since users will get prompted while device is idle if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && preferences.autoUpdateExtensions() != AutoUpdaterJob.NEVER) {
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && preferences.autoUpdateExtensions()) { val cm = context.connectivityManager
// val intent = ExtensionInstallService.jobIntent(context, extensions) if (
// context.startForegroundService(intent) preferences.autoUpdateExtensions() == AutoUpdaterJob.ALWAYS ||
// return !cm.isActiveNetworkMetered
// } ) {
val extensionManager = Injekt.get<ExtensionManager>()
val extensionsInstalledByApp =
extensions.filter { extensionManager.isInstalledByApp(it) }
val intent =
ExtensionInstallService.jobIntent(
context,
extensionsInstalledByApp,
// Re reun this job if not all the extensions can be auto updated
if (extensionsInstalledByApp.size == extensions.size) {
1
} else {
2
}
)
context.startForegroundService(intent)
if (extensionsInstalledByApp.size == extensions.size) {
return
} else {
extensions.removeAll(extensionsInstalledByApp)
}
} else {
runJobAgain(context, NetworkType.UNMETERED)
}
}
NotificationManagerCompat.from(context).apply { NotificationManagerCompat.from(context).apply {
notify( notify(
Notifications.ID_UPDATES_TO_EXTS, Notifications.ID_UPDATES_TO_EXTS,
@ -74,7 +103,9 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
context context
) )
) )
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
extensions.size == extensionsList.size
) {
val intent = ExtensionInstallService.jobIntent(context, extensions) val intent = ExtensionInstallService.jobIntent(context, extensions)
val pendingIntent = val pendingIntent =
PendingIntent.getForegroundService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) PendingIntent.getForegroundService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
@ -92,6 +123,20 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
companion object { companion object {
private const val TAG = "ExtensionUpdate" private const val TAG = "ExtensionUpdate"
private const val AUTO_TAG = "AutoExtensionUpdate"
fun runJobAgain(context: Context, networkType: NetworkType) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(networkType)
.build()
val request = OneTimeWorkRequestBuilder<ExtensionUpdateJob>()
.setConstraints(constraints)
.addTag(AUTO_TAG)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(AUTO_TAG, ExistingWorkPolicy.REPLACE, request)
}
fun setupTask(context: Context, forceAutoUpdateJob: Boolean? = null) { fun setupTask(context: Context, forceAutoUpdateJob: Boolean? = null) {
val preferences = Injekt.get<PreferencesHelper>() val preferences = Injekt.get<PreferencesHelper>()

View File

@ -4,7 +4,9 @@ import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build
import dalvik.system.PathClassLoader import dalvik.system.PathClassLoader
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.annotations.Nsfw import eu.kanade.tachiyomi.annotations.Nsfw
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
@ -85,6 +87,14 @@ internal object ExtensionLoader {
return loadExtension(context, pkgName, pkgInfo) return loadExtension(context, pkgName, pkgInfo)
} }
fun isExtensionInstalledByApp(context: Context, pkgName: String): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.packageManager.getInstallSourceInfo(pkgName).installingPackageName
} else {
context.packageManager.getInstallerPackageName(pkgName)
} == BuildConfig.APPLICATION_ID
}
/** /**
* Loads an extension given its package name. * Loads an extension given its package name.
* *

View File

@ -1,10 +1,12 @@
package eu.kanade.tachiyomi.ui.setting package eu.kanade.tachiyomi.ui.setting
import android.os.Build
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferenceKeys import eu.kanade.tachiyomi.data.preference.PreferenceKeys
import eu.kanade.tachiyomi.data.preference.asImmediateFlow import eu.kanade.tachiyomi.data.preference.asImmediateFlow
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.updater.AutoUpdaterJob
import eu.kanade.tachiyomi.extension.ExtensionUpdateJob import eu.kanade.tachiyomi.extension.ExtensionUpdateJob
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
@ -34,6 +36,20 @@ class SettingsBrowseController : SettingsController() {
true true
} }
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
intListPreference(activity) {
key = PreferenceKeys.autoUpdateExtensions
titleRes = R.string.auto_update_extensions
entryRange = 0..2
entriesRes = arrayOf(
R.string.over_any_network,
R.string.over_wifi_only,
R.string.dont_auto_update
)
defaultValue = AutoUpdaterJob.ONLY_ON_UNMETERED
}
infoPreference(R.string.some_extensions_may_not_update)
}
} }
preferenceCategory { preferenceCategory {

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp"
android:height="96dp"
android:viewportWidth="96"
android:viewportHeight="96">
<path
android:fillColor="#FF000000"
android:pathData="M50.9,85H25c-3.9,0 -7,-3.1 -7,-7V18c0,-3.9 3.1,-7 7,-7h46c3.9,0 7,3.1 7,7v37c-2.4,-0.9 -5,-1.4 -7.7,-1.4c-1.6,0 -3.1,0.2 -4.6,0.5C65.4,54 65.2,54 65,54h-3v-9c0,-2.8 -2.2,-5 -5,-5h-8v-2c0,-2.8 -2.2,-5 -5,-5s-5,2.2 -5,5v2h-8c-2.8,0 -5,2.2 -5,5v9h4c2.8,0 5,2.2 5,5s-2.2,5 -5,5h-4v8c0,2.8 2.2,5 5,5h8v-4c0,-2.8 2.2,-5 5,-5c2.3,0 4.3,1.6 4.8,3.8c-0.2,1.1 -0.3,2.3 -0.3,3.5C48.6,78.8 49.4,82.1 50.9,85zM81.7,62.3L66.9,77.1l-6.5,-6.5l-4.8,4.8l11.3,12.3l19.5,-19.5L81.7,62.3z"
android:fillType="evenOdd"/>
</vector>

View File

@ -292,6 +292,7 @@
<!-- Extensions --> <!-- Extensions -->
<string name="extensions">Extensions</string> <string name="extensions">Extensions</string>
<string name="extension_updates">Extension Updates</string> <string name="extension_updates">Extension Updates</string>
<string name="extension_updates_pending">Extension Updates pending</string>
<string name="extension_info">Extension info</string> <string name="extension_info">Extension info</string>
<string name="filter_languages">Filter Languages</string> <string name="filter_languages">Filter Languages</string>
<string name="obsolete">Obsolete</string> <string name="obsolete">Obsolete</string>
@ -321,6 +322,11 @@
<item quantity="one">%d update pending</item> <item quantity="one">%d update pending</item>
<item quantity="other">%d updates pending</item> <item quantity="other">%d updates pending</item>
</plurals> </plurals>
<string name="extensions_updated">Extensions updated</string>
<plurals name="extensions_updated_plural">
<item quantity="one">Extension updated</item>
<item quantity="other">%d extensions updated</item>
</plurals>
<plurals name="extension_updates_available"> <plurals name="extension_updates_available">
<item quantity="one">Extension update available</item> <item quantity="one">Extension update available</item>
<item quantity="other">%d extension updates available</item> <item quantity="other">%d extension updates available</item>
@ -770,6 +776,8 @@
<!-- Browse Settings --> <!-- Browse Settings -->
<string name="pref_global_search">Global search</string> <string name="pref_global_search">Global search</string>
<string name="check_for_extension_updates">Check for extension updates</string> <string name="check_for_extension_updates">Check for extension updates</string>
<string name="auto_update_extensions">Auto-update extensions</string>
<string name="some_extensions_may_not_update">Some extensions may not be auto-updated if they were installed outside this app</string>
<string name="only_search_pinned_when">Only search pinned sources</string> <string name="only_search_pinned_when">Only search pinned sources</string>
<string name="match_pinned_sources">Match pinned sources</string> <string name="match_pinned_sources">Match pinned sources</string>
<string name="match_enabled_sources">Match enabled sources</string> <string name="match_enabled_sources">Match enabled sources</string>