Improve tests

This commit is contained in:
CranberrySoup 2024-06-19 00:00:50 +02:00
parent 30d223cfe3
commit 4428431bad
9 changed files with 177 additions and 81 deletions

View file

@ -623,7 +623,7 @@ abstract class MainAPI {
open val usesWebView = false open val usesWebView = false
/** Determines which plugin a given provider is from */ /** Determines which plugin a given provider is from */
var sourcePlugin: String? = null var sourcePluginPath: String? = null
open val hasMainPage = false open val hasMainPage = false
open val hasQuickSearch = false open val hasQuickSearch = false

View file

@ -34,7 +34,7 @@ abstract class Plugin {
*/ */
fun registerMainAPI(element: MainAPI) { fun registerMainAPI(element: MainAPI) {
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI") Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI")
element.sourcePlugin = this.__filename element.sourcePluginPath = this.__filepath
// Race condition causing which would case duplicates if not for distinctBy // Race condition causing which would case duplicates if not for distinctBy
synchronized(APIHolder.allProviders) { synchronized(APIHolder.allProviders) {
APIHolder.allProviders.add(element) APIHolder.allProviders.add(element)
@ -48,7 +48,7 @@ abstract class Plugin {
*/ */
fun registerExtractorAPI(element: ExtractorApi) { fun registerExtractorAPI(element: ExtractorApi) {
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi") Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi")
element.sourcePlugin = this.__filename element.sourcePluginPath = this.__filepath
extractorApis.add(element) extractorApis.add(element)
} }
@ -67,7 +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
var __filename: String? = null var __filepath: String? = null
/** /**
* This will add a button in the settings allowing you to add custom settings * This will add a button in the settings allowing you to add custom settings

View file

@ -518,7 +518,7 @@ object PluginManager {
return true return true
} }
pluginInstance.__filename = fileName pluginInstance.__filepath = 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
@ -567,14 +567,14 @@ object PluginManager {
// remove all registered apis // remove all registered apis
synchronized(APIHolder.apis) { synchronized(APIHolder.apis) {
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.__filename }.forEach { APIHolder.apis.filter { api -> api.sourcePluginPath == plugin.__filepath }.forEach {
removePluginMapping(it) removePluginMapping(it)
} }
} }
synchronized(APIHolder.allProviders) { synchronized(APIHolder.allProviders) {
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.__filename } APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePluginPath == plugin.__filepath }
} }
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.__filename } extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePluginPath == plugin.__filepath }
classLoaders.values.removeIf { v -> v == plugin } classLoaders.values.removeIf { v -> v == plugin }

View file

@ -6,22 +6,28 @@ 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 +42,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 +59,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 +73,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.sourcePluginPath?.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

@ -1001,7 +1001,7 @@ abstract class ExtractorApi {
abstract val requiresReferer: Boolean abstract val requiresReferer: Boolean
/** Determines which plugin a given extractor is from */ /** Determines which plugin a given extractor is from */
var sourcePlugin: String? = null var sourcePluginPath: String? = null
//suspend fun getSafeUrl(url: String, referer: String? = null): List<ExtractorLink>? { //suspend fun getSafeUrl(url: String, referer: String? = null): List<ExtractorLink>? {
// return suspendSafeApiCall { getUrl(url, referer) } // return suspendSafeApiCall { getUrl(url, referer) }

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>
@ -604,6 +605,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>