Improve tests (#1142)

This commit is contained in:
CranberrySoup 2024-06-24 18:05:34 +00:00 committed by GitHub
parent b06d9f224d
commit 9ca1d02bdc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 172 additions and 77 deletions

View file

@ -622,7 +622,7 @@ abstract class MainAPI {
/**Used for testing and can be used to disable the providers if WebView is not available*/ /**Used for testing and can be used to disable the providers if WebView is not available*/
open val usesWebView = false open val usesWebView = false
/** Determines which plugin a given provider is from */ /** Determines which plugin a given provider is from. This is the full path to the plugin. */
var sourcePlugin: String? = null var sourcePlugin: String? = null
open val hasMainPage = false open val hasMainPage = false

View file

@ -67,6 +67,7 @@ abstract class Plugin {
* This will contain your resources if you specified requiresResources in gradle * This will contain your resources if you specified requiresResources in gradle
*/ */
var resources: Resources? = null var resources: Resources? = null
/** Full file path to the plugin. */
var __filename: String? = null var __filename: String? = null
/** /**

View file

@ -18,7 +18,6 @@ import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
import com.lagradost.cloudstream3.APIHolder.removePluginMapping import com.lagradost.cloudstream3.APIHolder.removePluginMapping
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
@ -518,7 +517,7 @@ object PluginManager {
return true return true
} }
pluginInstance.__filename = fileName pluginInstance.__filename = file.absolutePath
if (manifest.requiresResources) { if (manifest.requiresResources) {
Log.d(TAG, "Loading resources for ${data.internalName}") Log.d(TAG, "Loading resources for ${data.internalName}")
// based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk // based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk

View file

@ -2,26 +2,31 @@ package com.lagradost.cloudstream3.ui.settings.testing
import android.app.AlertDialog import android.app.AlertDialog
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.ProviderTestItemBinding import com.lagradost.cloudstream3.databinding.ProviderTestItemBinding
import com.lagradost.cloudstream3.mvvm.getAllMessages import com.lagradost.cloudstream3.mvvm.getAllMessages
import com.lagradost.cloudstream3.mvvm.getStackTracePretty import com.lagradost.cloudstream3.mvvm.getStackTracePretty
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppUtils
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso
import com.lagradost.cloudstream3.utils.TestingUtils import com.lagradost.cloudstream3.utils.TestingUtils
import java.io.File
class TestResultAdapter(override val items: MutableList<Pair<MainAPI, TestingUtils.TestResultProvider>>) : class TestResultAdapter(override val items: MutableList<Pair<MainAPI, TestingUtils.TestResultProvider>>) :
AppUtils.DiffAdapter<Pair<MainAPI, TestingUtils.TestResultProvider>>(items) { AppUtils.DiffAdapter<Pair<MainAPI, TestingUtils.TestResultProvider>>(items) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return ProviderTestViewHolder( return ProviderTestViewHolder(
ProviderTestItemBinding.inflate(LayoutInflater.from(parent.context), parent,false) ProviderTestItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
//LayoutInflater.from(parent.context) //LayoutInflater.from(parent.context)
// .inflate(R.layout.provider_test_item, parent, false), // .inflate(R.layout.provider_test_item, parent, false),
) )
@ -36,7 +41,8 @@ class TestResultAdapter(override val items: MutableList<Pair<MainAPI, TestingUti
} }
} }
inner class ProviderTestViewHolder(binding: ProviderTestItemBinding) : RecyclerView.ViewHolder(binding.root) { inner class ProviderTestViewHolder(binding: ProviderTestItemBinding) :
RecyclerView.ViewHolder(binding.root) {
private val languageText: TextView = binding.langIcon private val languageText: TextView = binding.langIcon
private val providerTitle: TextView = binding.mainText private val providerTitle: TextView = binding.mainText
private val statusText: TextView = binding.passedFailedMarker private val statusText: TextView = binding.passedFailedMarker
@ -52,7 +58,11 @@ class TestResultAdapter(override val items: MutableList<Pair<MainAPI, TestingUti
providerTitle.text = api.name providerTitle.text = api.name
val (resultText, resultColor) = if (result.success) { val (resultText, resultColor) = if (result.success) {
R.string.test_passed to R.color.colorTestPass if (result.log.any { it.level == TestingUtils.Logger.LogLevel.Warning }) {
R.string.test_warning to R.color.colorTestWarning
} else {
R.string.test_passed to R.color.colorTestPass
}
} else { } else {
R.string.test_failed to R.color.colorTestFail R.string.test_failed to R.color.colorTestFail
} }
@ -62,17 +72,43 @@ class TestResultAdapter(override val items: MutableList<Pair<MainAPI, TestingUti
val stackTrace = result.exception?.getStackTracePretty(false)?.ifBlank { null } val stackTrace = result.exception?.getStackTracePretty(false)?.ifBlank { null }
val messages = result.exception?.getAllMessages()?.ifBlank { null } val messages = result.exception?.getAllMessages()?.ifBlank { null }
val resultLog = result.log.joinToString("\n")
val fullLog = val fullLog =
result.log + (messages?.let { "\n\n$it" } ?: "") + (stackTrace?.let { "\n\n$it" } ?: "") resultLog +
(messages?.let { "\n\nError: $it" } ?: "") +
(stackTrace?.let { "\n\n$it" } ?: "")
failDescription.text = messages?.lastLine() ?: result.log.lastLine() failDescription.text = messages?.lastLine() ?: resultLog.lastLine()
logButton.setOnClickListener { logButton.setOnClickListener {
val builder: AlertDialog.Builder = val builder: AlertDialog.Builder =
AlertDialog.Builder(it.context, R.style.AlertDialogCustom) AlertDialog.Builder(it.context, R.style.AlertDialogCustom)
builder.setMessage(fullLog) builder.setMessage(fullLog)
.setTitle(R.string.test_log) .setTitle(R.string.test_log)
.show() // Ok button just closes the dialog
.setPositiveButton(R.string.ok) { _, _ -> }
api.sourcePlugin?.let { path ->
val pluginFile = File(path)
// Cannot delete a deleted plugin
if (!pluginFile.exists()) return@let
builder.setNegativeButton(R.string.delete_plugin) { _, _ ->
ioSafe {
val success = PluginManager.deletePlugin(pluginFile)
runOnMainThread {
if (success) {
showToast(R.string.plugin_deleted, Toast.LENGTH_SHORT)
} else {
showToast(R.string.error, Toast.LENGTH_SHORT)
}
}
}
}
}
builder.show()
} }
} }
} }

View file

@ -95,7 +95,7 @@ class TestViewModel : ViewModel() {
providers.clear() providers.clear()
updateProgress() updateProgress()
TestingUtils.getDeferredProviderTests(scope ?: return, apis, ::println) { api, result -> TestingUtils.getDeferredProviderTests(scope ?: return, apis) { api, result ->
addProvider(api, result) addProvider(api, result)
} }
} }

View file

@ -1015,7 +1015,7 @@ abstract class ExtractorApi {
abstract val mainUrl: String abstract val mainUrl: String
abstract val requiresReferer: Boolean abstract val requiresReferer: Boolean
/** Determines which plugin a given extractor is from */ /** Determines which plugin a given provider is from. This is the full path to the plugin. */
var sourcePlugin: String? = null var sourcePlugin: String? = null
//suspend fun getSafeUrl(url: String, referer: String? = null): List<ExtractorLink>? { //suspend fun getSafeUrl(url: String, referer: String? = null): List<ExtractorLink>? {

View file

@ -13,16 +13,55 @@ object TestingUtils {
} }
} }
class TestResultSearch(val results: List<SearchResponse>) : TestResult(true) class Logger {
class TestResultLoad(val extractorData: String) : TestResult(true) enum class LogLevel {
Normal,
Warning,
Error;
}
class TestResultProvider(success: Boolean, val log: String, val exception: Throwable?) : data class Message(val level: LogLevel, val message: String) {
override fun toString(): String {
val level = when (this.level) {
LogLevel.Normal -> ""
LogLevel.Warning -> "Warning: "
LogLevel.Error -> "Error: "
}
return "$level$message"
}
}
private val messageLog = mutableListOf<Message>()
fun getRawLog(): List<Message> = messageLog
fun log(message: String) {
messageLog.add(Message(LogLevel.Normal, message))
}
fun warn(message: String) {
messageLog.add(Message(LogLevel.Warning, message))
}
fun error(message: String) {
messageLog.add(Message(LogLevel.Error, message))
}
}
class TestResultList(val results: List<SearchResponse>) : TestResult(true)
class TestResultLoad(val extractorData: String, val shouldLoadLinks: Boolean) : TestResult(true)
class TestResultProvider(
success: Boolean,
val log: List<Logger.Message>,
val exception: Throwable?
) :
TestResult(success) TestResult(success)
@Throws(AssertionError::class, CancellationException::class) @Throws(AssertionError::class, CancellationException::class)
suspend fun testHomepage( suspend fun testHomepage(
api: MainAPI, api: MainAPI,
logger: (String) -> Unit logger: Logger
): TestResult { ): TestResult {
if (api.hasMainPage) { if (api.hasMainPage) {
try { try {
@ -31,22 +70,33 @@ object TestingUtils {
api.getMainPage(1, MainPageRequest(f.name, f.data, f.horizontalImages)) api.getMainPage(1, MainPageRequest(f.name, f.data, f.horizontalImages))
when { when {
homepage == null -> { homepage == null -> {
logger.invoke("Homepage provider ${api.name} did not correctly load homepage!") logger.error("Provider ${api.name} did not correctly load homepage!")
} }
homepage.items.isEmpty() -> { homepage.items.isEmpty() -> {
logger.invoke("Homepage provider ${api.name} does not contain any items!") logger.warn("Provider ${api.name} does not contain any homepage rows!")
} }
homepage.items.any { it.list.isEmpty() } -> { homepage.items.any { it.list.isEmpty() } -> {
logger.invoke("Homepage provider ${api.name} does not have any items on result!") logger.warn("Provider ${api.name} does not have any items in a homepage row!")
} }
} }
val homePageList = homepage?.items?.flatMap { it.list } ?: emptyList()
return TestResultList(homePageList)
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is NotImplementedError) { when (e) {
Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented") is NotImplementedError -> {
} else if (e is CancellationException) { Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented")
throw e }
is CancellationException -> {
throw e
}
else -> {
e.message?.let { logger.warn("Exception thrown when loading homepage: \"$it\"") }
}
} }
logError(e)
} }
} }
return TestResult.Pass return TestResult.Pass
@ -54,11 +104,13 @@ object TestingUtils {
@Throws(AssertionError::class, CancellationException::class) @Throws(AssertionError::class, CancellationException::class)
private suspend fun testSearch( private suspend fun testSearch(
api: MainAPI api: MainAPI,
testQueries: List<String>,
logger: Logger,
): TestResult { ): TestResult {
val searchQueries = listOf("over", "iron", "guy") val searchResults = testQueries.firstNotNullOfOrNull { query ->
val searchResults = searchQueries.firstNotNullOfOrNull { query ->
try { try {
logger.log("Searching for: $query")
api.search(query).takeIf { !it.isNullOrEmpty() } api.search(query).takeIf { !it.isNullOrEmpty() }
} catch (e: Throwable) { } catch (e: Throwable) {
if (e is NotImplementedError) { if (e is NotImplementedError) {
@ -72,12 +124,11 @@ object TestingUtils {
} }
return if (searchResults.isNullOrEmpty()) { return if (searchResults.isNullOrEmpty()) {
Assert.fail("Api ${api.name} did not return any valid search responses") Assert.fail("Api ${api.name} did not return any search responses")
TestResult.Fail // Should not be reached TestResult.Fail // Should not be reached
} else { } else {
TestResultSearch(searchResults) TestResultList(searchResults)
} }
} }
@ -85,31 +136,27 @@ object TestingUtils {
private suspend fun testLoad( private suspend fun testLoad(
api: MainAPI, api: MainAPI,
result: SearchResponse, result: SearchResponse,
logger: (String) -> Unit logger: Logger
): TestResult { ): TestResult {
try { try {
Assert.assertEquals( if (result.apiName != api.name) {
"Invalid apiName on SearchResponse on ${api.name}", logger.warn("Wrong apiName on SearchResponse: ${api.name} != ${result.apiName}")
result.apiName, }
api.name
)
val loadResponse = api.load(result.url) val loadResponse = api.load(result.url)
if (loadResponse == null) { if (loadResponse == null) {
logger.invoke("Returned null loadResponse on ${result.url} on ${api.name}") logger.error("Returned null loadResponse on ${result.url} on ${api.name}")
return TestResult.Fail return TestResult.Fail
} }
Assert.assertEquals( if (loadResponse.apiName != api.name) {
"Invalid apiName on LoadResponse on ${api.name}", logger.warn("Wrong apiName on LoadResponse: ${api.name} != ${loadResponse.apiName}")
loadResponse.apiName, }
result.apiName
) if (!api.supportedTypes.contains(loadResponse.type)) {
Assert.assertTrue( logger.warn("Api ${api.name} on load does not contain any of the supportedTypes: ${loadResponse.type}")
"Api ${api.name} on load does not contain any of the supportedTypes: ${loadResponse.type}", }
api.supportedTypes.contains(loadResponse.type)
)
val url = when (loadResponse) { val url = when (loadResponse) {
is AnimeLoadResponse -> { is AnimeLoadResponse -> {
@ -117,39 +164,43 @@ object TestingUtils {
loadResponse.episodes.keys.isEmpty() || loadResponse.episodes.keys.any { loadResponse.episodes[it].isNullOrEmpty() } loadResponse.episodes.keys.isEmpty() || loadResponse.episodes.keys.any { loadResponse.episodes[it].isNullOrEmpty() }
if (gotNoEpisodes) { if (gotNoEpisodes) {
logger.invoke("Api ${api.name} got no episodes on ${loadResponse.url}") logger.error("Api ${api.name} got no episodes on ${loadResponse.url}")
return TestResult.Fail return TestResult.Fail
} }
(loadResponse.episodes[loadResponse.episodes.keys.firstOrNull()])?.firstOrNull()?.data (loadResponse.episodes[loadResponse.episodes.keys.firstOrNull()])?.firstOrNull()?.data
} }
is MovieLoadResponse -> { is MovieLoadResponse -> {
val gotNoEpisodes = loadResponse.dataUrl.isBlank() val gotNoEpisodes = loadResponse.dataUrl.isBlank()
if (gotNoEpisodes) { if (gotNoEpisodes) {
logger.invoke("Api ${api.name} got no movie on ${loadResponse.url}") logger.error("Api ${api.name} got no movie on ${loadResponse.url}")
return TestResult.Fail return TestResult.Fail
} }
loadResponse.dataUrl loadResponse.dataUrl
} }
is TvSeriesLoadResponse -> { is TvSeriesLoadResponse -> {
val gotNoEpisodes = loadResponse.episodes.isEmpty() val gotNoEpisodes = loadResponse.episodes.isEmpty()
if (gotNoEpisodes) { if (gotNoEpisodes) {
logger.invoke("Api ${api.name} got no episodes on ${loadResponse.url}") logger.error("Api ${api.name} got no episodes on ${loadResponse.url}")
return TestResult.Fail return TestResult.Fail
} }
loadResponse.episodes.firstOrNull()?.data loadResponse.episodes.firstOrNull()?.data
} }
is LiveStreamLoadResponse -> { is LiveStreamLoadResponse -> {
loadResponse.dataUrl loadResponse.dataUrl
} }
else -> { else -> {
logger.invoke("Unknown load response: ${loadResponse.javaClass.name}") logger.error("Unknown load response: ${loadResponse.javaClass.name}")
return TestResult.Fail return TestResult.Fail
} }
} ?: return TestResult.Fail } ?: return TestResult.Fail
return TestResultLoad(url) return TestResultLoad(url, loadResponse.type != TvType.CustomMedia)
// val loadTest = testLoadResponse(api, load, logger) // val loadTest = testLoadResponse(api, load, logger)
// if (loadTest is TestResultLoad) { // if (loadTest is TestResultLoad) {
@ -174,7 +225,7 @@ object TestingUtils {
private suspend fun testLinkLoading( private suspend fun testLinkLoading(
api: MainAPI, api: MainAPI,
url: String?, url: String?,
logger: (String) -> Unit logger: Logger
): TestResult { ): TestResult {
Assert.assertNotNull("Api ${api.name} has invalid url on episode", url) Assert.assertNotNull("Api ${api.name} has invalid url on episode", url)
if (url == null) return TestResult.Fail // Should never trigger if (url == null) return TestResult.Fail // Should never trigger
@ -182,7 +233,7 @@ object TestingUtils {
var linksLoaded = 0 var linksLoaded = 0
try { try {
val success = api.loadLinks(url, false, {}) { link -> val success = api.loadLinks(url, false, {}) { link ->
logger.invoke("Video loaded: ${link.name}") logger.log("Video loaded: ${link.name}")
Assert.assertTrue( Assert.assertTrue(
"Api ${api.name} returns link with invalid url ${link.url}", "Api ${api.name} returns link with invalid url ${link.url}",
link.url.length > 4 link.url.length > 4
@ -190,7 +241,7 @@ object TestingUtils {
linksLoaded++ linksLoaded++
} }
if (success) { if (success) {
logger.invoke("Links loaded: $linksLoaded") logger.log("Links loaded: $linksLoaded")
return TestResult(linksLoaded > 0) return TestResult(linksLoaded > 0)
} else { } else {
Assert.fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded") Assert.fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded")
@ -200,8 +251,9 @@ object TestingUtils {
is NotImplementedError -> { is NotImplementedError -> {
Assert.fail("Provider has not implemented loadLinks()") Assert.fail("Provider has not implemented loadLinks()")
} }
else -> { else -> {
logger.invoke("Failed link loading on ${api.name} using data: $url") logger.error("Failed link loading on ${api.name} using data: $url")
throw e throw e
} }
} }
@ -212,53 +264,57 @@ object TestingUtils {
fun getDeferredProviderTests( fun getDeferredProviderTests(
scope: CoroutineScope, scope: CoroutineScope,
providers: Array<MainAPI>, providers: Array<MainAPI>,
logger: (String) -> Unit,
callback: (MainAPI, TestResultProvider) -> Unit callback: (MainAPI, TestResultProvider) -> Unit
) { ) {
providers.forEach { api -> providers.forEach { api ->
scope.launch { scope.launch {
var log = "" val logger = Logger()
fun addToLog(string: String) {
log += string + "\n"
logger.invoke(string)
}
fun getLog(): String {
return log.removeSuffix("\n")
}
val result = try { val result = try {
addToLog("Trying ${api.name}") logger.log("Trying ${api.name}")
// Test Homepage // Test Homepage
val homepage = testHomepage(api, logger).success val homepage = testHomepage(api, logger)
Assert.assertTrue("Homepage failed to load", homepage) Assert.assertTrue("Homepage failed to load", homepage.success)
val homePageList = (homepage as? TestResultList)?.results ?: emptyList()
// Test Search Results // Test Search Results
val searchResults = testSearch(api) val searchQueries =
// Use the first 3 home page results as queries since they are guaranteed to exist
(homePageList.take(3).map { it.name } +
// If home page is sparse then use generic search queries
listOf("over", "iron", "guy")).take(3)
val searchResults = testSearch(api, searchQueries, logger)
Assert.assertTrue("Failed to get search results", searchResults.success) Assert.assertTrue("Failed to get search results", searchResults.success)
searchResults as TestResultSearch searchResults as TestResultList
// Test Load and LoadLinks // Test Load and LoadLinks
// Only try the first 3 search results to prevent spamming // Only try the first 3 search results to prevent spamming
val success = searchResults.results.take(3).any { searchResponse -> val success = searchResults.results.take(3).any { searchResponse ->
addToLog("Testing search result: ${searchResponse.url}") logger.log("Testing search result: ${searchResponse.url}")
val loadResponse = testLoad(api, searchResponse, ::addToLog) val loadResponse = testLoad(api, searchResponse, logger)
if (loadResponse !is TestResultLoad) { if (loadResponse !is TestResultLoad) {
false false
} else { } else {
testLinkLoading(api, loadResponse.extractorData, ::addToLog).success if (loadResponse.shouldLoadLinks) {
testLinkLoading(api, loadResponse.extractorData, logger).success
} else {
logger.log("Skipping link loading test")
true
}
} }
} }
if (success) { if (success) {
logger.invoke("Success ${api.name}") logger.log("Success ${api.name}")
TestResultProvider(true, getLog(), null) TestResultProvider(true, logger.getRawLog(), null)
} else { } else {
logger.invoke("Error ${api.name}") logger.error("Link loading failed")
TestResultProvider(false, getLog(), null) TestResultProvider(false, logger.getRawLog(), null)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
TestResultProvider(false, getLog(), e) TestResultProvider(false, logger.getRawLog(), e)
} }
callback.invoke(api, result) callback.invoke(api, result)
} }

View file

@ -88,4 +88,5 @@
<color name="colorTestPass">#48E484</color> <color name="colorTestPass">#48E484</color>
<color name="colorTestFail">#ea596e</color> <color name="colorTestFail">#ea596e</color>
<color name="colorTestWarning">#FF9800</color>
</resources> </resources>

View file

@ -304,6 +304,7 @@
<string name="start">Start</string> <string name="start">Start</string>
<string name="test_failed">Failed</string> <string name="test_failed">Failed</string>
<string name="test_passed">Passed</string> <string name="test_passed">Passed</string>
<string name="test_warning">Warning</string>
<string name="resume">Resume</string> <string name="resume">Resume</string>
<string name="go_back_30">-30</string> <string name="go_back_30">-30</string>
<string name="go_forward_30">+30</string> <string name="go_forward_30">+30</string>
@ -609,6 +610,7 @@
<string name="plugin">plugins</string> <string name="plugin">plugins</string>
<string name="delete_repository_plugins">This will also delete all repository plugins</string> <string name="delete_repository_plugins">This will also delete all repository plugins</string>
<string name="delete_repository">Delete repository</string> <string name="delete_repository">Delete repository</string>
<string name="delete_plugin">Delete plugin</string>
<string name="setup_extensions_subtext">Download the list of sites you want to use</string> <string name="setup_extensions_subtext">Download the list of sites you want to use</string>
<string name="plugins_downloaded" formatted="true">Downloaded: %d</string> <string name="plugins_downloaded" formatted="true">Downloaded: %d</string>
<string name="plugins_disabled" formatted="true">Disabled: %d</string> <string name="plugins_disabled" formatted="true">Disabled: %d</string>