Merge remote-tracking branch 'csupdate/master'

This commit is contained in:
Stormunblessed 2023-02-17 18:51:15 -06:00
commit 0aa0cd3d91
No known key found for this signature in database
GPG key ID: CE92471F93C0CAB4
28 changed files with 1199 additions and 274 deletions

View file

@ -1,9 +1,8 @@
package com.lagradost.cloudstream3 package com.lagradost.cloudstream3
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.SubtitleHelper
import com.lagradost.cloudstream3.utils.TestingUtils
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert import org.junit.Assert
import org.junit.Test import org.junit.Test
@ -16,142 +15,11 @@ import org.junit.runner.RunWith
*/ */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest { class ExampleInstrumentedTest {
//@Test
//fun useAppContext() {
// // Context of the app under test.
// val appContext = InstrumentationRegistry.getInstrumentation().targetContext
// assertEquals("com.lagradost.cloudstream3", appContext.packageName)
//}
private fun getAllProviders(): List<MainAPI> { private fun getAllProviders(): List<MainAPI> {
println("Providers: ${APIHolder.allProviders.size}")
return APIHolder.allProviders //.filter { !it.usesWebView } return APIHolder.allProviders //.filter { !it.usesWebView }
} }
private suspend fun loadLinks(api: MainAPI, url: String?): Boolean {
Assert.assertNotNull("Api ${api.name} has invalid url on episode", url)
if (url == null) return true
var linksLoaded = 0
try {
val success = api.loadLinks(url, false, {}) { link ->
Assert.assertTrue(
"Api ${api.name} returns link with invalid Quality",
Qualities.values().map { it.value }.contains(link.quality)
)
Assert.assertTrue(
"Api ${api.name} returns link with invalid url ${link.url}",
link.url.length > 4
)
linksLoaded++
}
if (success) {
return linksLoaded > 0
}
Assert.assertTrue("Api ${api.name} has returns false on .loadLinks", success)
} catch (e: Exception) {
if (e.cause is NotImplementedError) {
Assert.fail("Provider has not implemented .loadLinks")
}
logError(e)
}
return true
}
private suspend fun testSingleProviderApi(api: MainAPI): Boolean {
val searchQueries = listOf("over", "iron", "guy")
var correctResponses = 0
var searchResult: List<SearchResponse>? = null
for (query in searchQueries) {
val response = try {
api.search(query)
} catch (e: Exception) {
if (e.cause is NotImplementedError) {
Assert.fail("Provider has not implemented .search")
}
logError(e)
null
}
if (!response.isNullOrEmpty()) {
correctResponses++
if (searchResult == null) {
searchResult = response
}
}
}
if (correctResponses == 0 || searchResult == null) {
System.err.println("Api ${api.name} did not return any valid search responses")
return false
}
try {
var validResults = false
for (result in searchResult) {
Assert.assertEquals(
"Invalid apiName on response on ${api.name}",
result.apiName,
api.name
)
val load = api.load(result.url) ?: continue
Assert.assertEquals(
"Invalid apiName on load on ${api.name}",
load.apiName,
result.apiName
)
Assert.assertTrue(
"Api ${api.name} on load does not contain any of the supportedTypes",
api.supportedTypes.contains(load.type)
)
when (load) {
is AnimeLoadResponse -> {
val gotNoEpisodes =
load.episodes.keys.isEmpty() || load.episodes.keys.any { load.episodes[it].isNullOrEmpty() }
if (gotNoEpisodes) {
println("Api ${api.name} got no episodes on ${load.url}")
continue
}
val url = (load.episodes[load.episodes.keys.first()])?.first()?.data
validResults = loadLinks(api, url)
if (!validResults) continue
}
is MovieLoadResponse -> {
val gotNoEpisodes = load.dataUrl.isBlank()
if (gotNoEpisodes) {
println("Api ${api.name} got no movie on ${load.url}")
continue
}
validResults = loadLinks(api, load.dataUrl)
if (!validResults) continue
}
is TvSeriesLoadResponse -> {
val gotNoEpisodes = load.episodes.isEmpty()
if (gotNoEpisodes) {
println("Api ${api.name} got no episodes on ${load.url}")
continue
}
validResults = loadLinks(api, load.episodes.first().data)
if (!validResults) continue
}
}
break
}
if (!validResults) {
System.err.println("Api ${api.name} did not load on any")
}
return validResults
} catch (e: Exception) {
if (e.cause is NotImplementedError) {
Assert.fail("Provider has not implemented .load")
}
logError(e)
return false
}
}
@Test @Test
fun providersExist() { fun providersExist() {
Assert.assertTrue(getAllProviders().isNotEmpty()) Assert.assertTrue(getAllProviders().isNotEmpty())
@ -159,6 +27,7 @@ class ExampleInstrumentedTest {
} }
@Test @Test
@Throws(AssertionError::class)
fun providerCorrectData() { fun providerCorrectData() {
val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 } val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 }
Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty()) Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty())
@ -181,67 +50,20 @@ class ExampleInstrumentedTest {
fun providerCorrectHomepage() { fun providerCorrectHomepage() {
runBlocking { runBlocking {
getAllProviders().amap { api -> getAllProviders().amap { api ->
if (api.hasMainPage) { TestingUtils.testHomepage(api, ::println)
try {
val f = api.mainPage.first()
val homepage =
api.getMainPage(1, MainPageRequest(f.name, f.data, f.horizontalImages))
when {
homepage == null -> {
System.err.println("Homepage provider ${api.name} did not correctly load homepage!")
}
homepage.items.isEmpty() -> {
System.err.println("Homepage provider ${api.name} does not contain any items!")
}
homepage.items.any { it.list.isEmpty() } -> {
System.err.println("Homepage provider ${api.name} does not have any items on result!")
}
}
} catch (e: Exception) {
if (e.cause is NotImplementedError) {
Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented")
}
logError(e)
}
}
} }
} }
println("Done providerCorrectHomepage") println("Done providerCorrectHomepage")
} }
// @Test
// fun testSingleProvider() {
// testSingleProviderApi(ThenosProvider())
// }
@Test @Test
fun providerCorrect() { fun testAllProvidersCorrect() {
runBlocking { runBlocking {
val invalidProvider = ArrayList<Pair<MainAPI, Exception?>>() TestingUtils.getDeferredProviderTests(
val providers = getAllProviders() this,
providers.amap { api -> getAllProviders(),
try { ::println
println("Trying $api") ) { _, _ -> }
if (testSingleProviderApi(api)) {
println("Success $api")
} else {
System.err.println("Error $api")
invalidProvider.add(Pair(api, null))
}
} catch (e: Exception) {
logError(e)
invalidProvider.add(Pair(api, e))
}
}
if (invalidProvider.isEmpty()) {
println("No Invalid providers! :D")
} else {
println("Invalid providers are: ")
for (provider in invalidProvider) {
println("${provider.first}")
} }
} }
} }
println("Done providerCorrect")
}
}

View file

@ -17,8 +17,10 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.*
import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.Coroutines.mainWork
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import okhttp3.Interceptor import okhttp3.Interceptor
import org.mozilla.javascript.Scriptable
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
@ -734,6 +736,19 @@ fun fixTitle(str: String): String {
.replaceFirstChar { char -> if (char.isLowerCase()) char.titlecase(Locale.getDefault()) else it } .replaceFirstChar { char -> if (char.isLowerCase()) char.titlecase(Locale.getDefault()) else it }
} }
} }
/**
* Get rhino context in a safe way as it needs to be initialized on the main thread.
* Make sure you get the scope using: val scope: Scriptable = rhino.initSafeStandardObjects()
* Use like the following: rhino.evaluateString(scope, js, "JavaScript", 1, null)
**/
suspend fun getRhinoContext(): org.mozilla.javascript.Context {
return Coroutines.mainWork {
val rhino = org.mozilla.javascript.Context.enter()
rhino.initSafeStandardObjects()
rhino.optimizationLevel = -1
rhino
}
}
/** https://www.imdb.com/title/tt2861424/ -> tt2861424 */ /** https://www.imdb.com/title/tt2861424/ -> tt2861424 */
fun imdbUrlToId(url: String): String? { fun imdbUrlToId(url: String): String? {

View file

@ -402,6 +402,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
R.id.navigation_settings_general, R.id.navigation_settings_general,
R.id.navigation_settings_extensions, R.id.navigation_settings_extensions,
R.id.navigation_settings_plugins, R.id.navigation_settings_plugins,
R.id.navigation_test_providers,
).contains(destination.id) ).contains(destination.id)

View file

@ -121,13 +121,21 @@ suspend fun <T> suspendSafeApiCall(apiCall: suspend () -> T): T? {
} }
} }
fun <T> safeFail(throwable: Throwable): Resource<T> { fun Throwable.getAllMessages(): String {
val stackTraceMsg = return (this.localizedMessage ?: "") + (this.cause?.getAllMessages()?.let { "\n$it" } ?: "")
(throwable.localizedMessage ?: "") + "\n\n" + throwable.stackTrace.joinToString( }
fun Throwable.getStackTracePretty(showMessage: Boolean = true): String {
val prefix = if (showMessage) this.localizedMessage?.let { "\n$it" } ?: "" else ""
return prefix + this.stackTrace.joinToString(
separator = "\n" separator = "\n"
) { ) {
"${it.fileName} ${it.lineNumber}" "${it.fileName} ${it.lineNumber}"
} }
}
fun <T> safeFail(throwable: Throwable): Resource<T> {
val stackTraceMsg = throwable.getStackTracePretty()
return Resource.Failure(false, null, null, stackTraceMsg) return Resource.Failure(false, null, null, stackTraceMsg)
} }

View file

@ -1,11 +1,22 @@
package com.lagradost.cloudstream3.services package com.lagradost.cloudstream3.services
import android.app.Service
import android.app.IntentService
import android.content.Intent import android.content.Intent
import android.os.IBinder
import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class VideoDownloadService : IntentService("VideoDownloadService") { class VideoDownloadService : Service() {
override fun onHandleIntent(intent: Intent?) {
private val downloadScope = CoroutineScope(Dispatchers.Default)
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null) { if (intent != null) {
val id = intent.getIntExtra("id", -1) val id = intent.getIntExtra("id", -1)
val type = intent.getStringExtra("type") val type = intent.getStringExtra("type")
@ -14,10 +25,36 @@ class VideoDownloadService : IntentService("VideoDownloadService") {
"resume" -> VideoDownloadManager.DownloadActionType.Resume "resume" -> VideoDownloadManager.DownloadActionType.Resume
"pause" -> VideoDownloadManager.DownloadActionType.Pause "pause" -> VideoDownloadManager.DownloadActionType.Pause
"stop" -> VideoDownloadManager.DownloadActionType.Stop "stop" -> VideoDownloadManager.DownloadActionType.Stop
else -> return else -> return START_NOT_STICKY
} }
downloadScope.launch {
VideoDownloadManager.downloadEvent.invoke(Pair(id, state)) VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
} }
} }
} }
return START_NOT_STICKY
} }
override fun onDestroy() {
downloadScope.coroutineContext.cancel()
super.onDestroy()
}
}
// override fun onHandleIntent(intent: Intent?) {
// if (intent != null) {
// val id = intent.getIntExtra("id", -1)
// val type = intent.getStringExtra("type")
// if (id != -1 && type != null) {
// val state = when (type) {
// "resume" -> VideoDownloadManager.DownloadActionType.Resume
// "pause" -> VideoDownloadManager.DownloadActionType.Pause
// "stop" -> VideoDownloadManager.DownloadActionType.Stop
// else -> return
// }
// VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
// }
// }
// }
//}

View file

@ -759,6 +759,11 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
return data != "" return data != ""
} }
/** Used to query a saved MediaItem on the list to get the id for removal */
data class MediaListItemRoot(@JsonProperty("data") val data: MediaListItem? = null)
data class MediaListItem(@JsonProperty("MediaList") val MediaList: MediaListId? = null)
data class MediaListId(@JsonProperty("id") val id: Long? = null)
private suspend fun postDataAboutId( private suspend fun postDataAboutId(
id: Int, id: Int,
type: AniListStatusType, type: AniListStatusType,
@ -766,6 +771,28 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
progress: Int? progress: Int?
): Boolean { ): Boolean {
val q = val q =
// Delete item if status type is None
if (type == AniListStatusType.None) {
val userID = getKey<AniListUser>(accountId, ANILIST_USER_KEY)?.id ?: return false
// Get list ID for deletion
val idQuery = """
query MediaList(${'$'}userId: Int = $userID, ${'$'}mediaId: Int = $id) {
MediaList(userId: ${'$'}userId, mediaId: ${'$'}mediaId) {
id
}
}
"""
val response = postApi(idQuery)
val listId =
tryParseJson<MediaListItemRoot>(response)?.data?.MediaList?.id ?: return false
"""
mutation(${'$'}id: Int = $listId) {
DeleteMediaListEntry(id: ${'$'}id) {
deleted
}
}
"""
} else {
"""mutation (${'$'}id: Int = $id, ${'$'}status: MediaListStatus = ${ """mutation (${'$'}id: Int = $id, ${'$'}status: MediaListStatus = ${
aniListStatusString[maxOf( aniListStatusString[maxOf(
0, 0,
@ -779,6 +806,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
score score
} }
}""" }"""
}
val data = postApi(q) val data = postApi(q)
return data != "" return data != ""
} }

View file

@ -322,9 +322,7 @@ class ResultFragmentPhone : ResultFragment() {
// it?.dismiss() // it?.dismiss()
//} //}
builder.setCanceledOnTouchOutside(true) builder.setCanceledOnTouchOutside(true)
builder.show() builder.show()
builder builder
} }
} }

View file

@ -176,8 +176,7 @@ class ResultFragmentTv : ResultFragment() {
loadingDialog = null loadingDialog = null
} }
loadingDialog = loadingDialog ?: context?.let { ctx -> loadingDialog = loadingDialog ?: context?.let { ctx ->
val builder = val builder = BottomSheetDialog(ctx)
BottomSheetDialog(ctx)
builder.setContentView(R.layout.bottom_loading) builder.setContentView(R.layout.bottom_loading)
builder.setOnDismissListener { builder.setOnDismissListener {
loadingDialog = null loadingDialog = null
@ -187,9 +186,7 @@ class ResultFragmentTv : ResultFragment() {
// it?.dismiss() // it?.dismiss()
//} //}
builder.setCanceledOnTouchOutside(true) builder.setCanceledOnTouchOutside(true)
builder.show() builder.show()
builder builder
} }
} }

View file

@ -68,12 +68,12 @@ val appLanguages = arrayListOf(
Triple("", "español", "es"), Triple("", "español", "es"),
Triple("", "فارسی", "fa"), Triple("", "فارسی", "fa"),
Triple("", "français", "fr"), Triple("", "français", "fr"),
Triple("\uD83C\uDDEE\uD83C\uDDF1", "עברית", "iw"),
Triple("", "हिन्दी", "hi"), Triple("", "हिन्दी", "hi"),
Triple("", "hrvatski", "hr"), Triple("", "hrvatski", "hr"),
Triple("", "magyar", "hu"), Triple("", "magyar", "hu"),
Triple("\uD83C\uDDEE\uD83C\uDDE9", "Bahasa Indonesia", "in"), Triple("\uD83C\uDDEE\uD83C\uDDE9", "Bahasa Indonesia", "in"),
Triple("", "italiano", "it"), Triple("", "italiano", "it"),
Triple("\uD83C\uDDEE\uD83C\uDDF1", "עברית", "iw"),
Triple("", "ಕನ್ನಡ", "kn"), Triple("", "ಕನ್ನಡ", "kn"),
Triple("", "македонски", "mk"), Triple("", "македонски", "mk"),
Triple("", "മലയാളം", "ml"), Triple("", "മലയാളം", "ml"),

View file

@ -2,6 +2,8 @@ package com.lagradost.cloudstream3.ui.settings
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.navigation.NavOptions
import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.*
@ -16,6 +18,7 @@ import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.SubtitleHelper
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate
class SettingsProviders : PreferenceFragmentCompat() { class SettingsProviders : PreferenceFragmentCompat() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -56,6 +59,20 @@ class SettingsProviders : PreferenceFragmentCompat() {
return@setOnPreferenceClickListener true return@setOnPreferenceClickListener true
} }
getPref(R.string.test_providers_key)?.setOnPreferenceClickListener {
// Somehow animations do not work without this.
val options = NavOptions.Builder()
.setEnterAnim(R.anim.enter_anim)
.setExitAnim(R.anim.exit_anim)
.setPopEnterAnim(R.anim.pop_enter)
.setPopExitAnim(R.anim.pop_exit)
.build()
this@SettingsProviders.findNavController()
.navigate(R.id.navigation_test_providers, null, options)
true
}
getPref(R.string.prefer_media_type_key)?.setOnPreferenceClickListener { getPref(R.string.prefer_media_type_key)?.setOnPreferenceClickListener {
val names = enumValues<TvType>().sorted().map { it.name } val names = enumValues<TvType>().sorted().map { it.name }
val default = val default =

View file

@ -0,0 +1,97 @@
package com.lagradost.cloudstream3.ui.settings.testing
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.fragment.app.activityViewModels
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
import kotlinx.android.synthetic.main.fragment_testing.*
import kotlinx.android.synthetic.main.view_test.*
class TestFragment : Fragment() {
private val testViewModel: TestViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
setUpToolbar(R.string.category_provider_test)
super.onViewCreated(view, savedInstanceState)
provider_test_recycler_view?.adapter = TestResultAdapter(
mutableListOf()
)
testViewModel.init()
if (testViewModel.isRunningTest) {
provider_test?.setState(TestView.TestState.Running)
}
observe(testViewModel.providerProgress) { (passed, failed, total) ->
provider_test?.setProgress(passed, failed, total)
}
observeNullable(testViewModel.providerResults) {
normalSafeApiCall {
val newItems = it.sortedBy { api -> api.first.name }
(provider_test_recycler_view?.adapter as? TestResultAdapter)?.updateList(
newItems
)
}
}
provider_test?.setOnPlayButtonListener { state ->
when (state) {
TestView.TestState.Stopped -> testViewModel.stopTest()
TestView.TestState.Running -> testViewModel.startTest()
TestView.TestState.None -> testViewModel.startTest()
}
}
if (isTrueTvSettings()) {
tests_play_pause?.isFocusableInTouchMode = true
tests_play_pause?.requestFocus()
}
provider_test?.playPauseButton?.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
provider_test_appbar?.setExpanded(true, true)
}
}
fun focusRecyclerView() {
// Hack to make it possible to focus the recyclerview.
if (isTrueTvSettings()) {
provider_test_recycler_view?.requestFocus()
provider_test_appbar?.setExpanded(false, true)
}
}
provider_test?.setOnMainClick {
testViewModel.setFilterMethod(TestViewModel.ProviderFilter.All)
focusRecyclerView()
}
provider_test?.setOnFailedClick {
testViewModel.setFilterMethod(TestViewModel.ProviderFilter.Failed)
focusRecyclerView()
}
provider_test?.setOnPassedClick {
testViewModel.setFilterMethod(TestViewModel.ProviderFilter.Passed)
focusRecyclerView()
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_testing, container, false)
}
}

View file

@ -0,0 +1,80 @@
package com.lagradost.cloudstream3.ui.settings.testing
import android.app.AlertDialog
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.getAllMessages
import com.lagradost.cloudstream3.mvvm.getStackTracePretty
import com.lagradost.cloudstream3.utils.AppUtils
import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso
import com.lagradost.cloudstream3.utils.TestingUtils
import kotlinx.android.synthetic.main.provider_test_item.view.*
class TestResultAdapter(override val items: MutableList<Pair<MainAPI, TestingUtils.TestResultProvider>>) :
AppUtils.DiffAdapter<Pair<MainAPI, TestingUtils.TestResultProvider>>(items) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return ProviderTestViewHolder(
LayoutInflater.from(parent.context)
.inflate(R.layout.provider_test_item, parent, false),
)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is ProviderTestViewHolder -> {
val item = items[position]
holder.bind(item.first, item.second)
}
}
}
inner class ProviderTestViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val languageText: TextView = itemView.lang_icon
private val providerTitle: TextView = itemView.main_text
private val statusText: TextView = itemView.passed_failed_marker
private val failDescription: TextView = itemView.fail_description
private val logButton: ImageView = itemView.action_button
private fun String.lastLine(): String? {
return this.lines().lastOrNull { it.isNotBlank() }
}
fun bind(api: MainAPI, result: TestingUtils.TestResultProvider) {
languageText.text = getFlagFromIso(api.lang)
providerTitle.text = api.name
val (resultText, resultColor) = if (result.success) {
R.string.test_passed to R.color.colorTestPass
} else {
R.string.test_failed to R.color.colorTestFail
}
statusText.setText(resultText)
statusText.setTextColor(ContextCompat.getColor(itemView.context, resultColor))
val stackTrace = result.exception?.getStackTracePretty(false)?.ifBlank { null }
val messages = result.exception?.getAllMessages()?.ifBlank { null }
val fullLog =
result.log + (messages?.let { "\n\n$it" } ?: "") + (stackTrace?.let { "\n\n$it" } ?: "")
failDescription.text = messages?.lastLine() ?: result.log.lastLine()
logButton.setOnClickListener {
val builder: AlertDialog.Builder =
AlertDialog.Builder(it.context, R.style.AlertDialogCustom)
builder.setMessage(fullLog)
.setTitle(R.string.test_log)
.show()
}
}
}
}

View file

@ -0,0 +1,119 @@
package com.lagradost.cloudstream3.ui.settings.testing
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.widget.ContentLoadingProgressBar
import com.google.android.material.button.MaterialButton
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.AppUtils.animateProgressTo
class TestView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : CardView(context, attrs) {
enum class TestState(@StringRes val stringRes: Int, @DrawableRes val icon: Int) {
None(R.string.start, R.drawable.ic_baseline_play_arrow_24),
// Paused(R.string.resume, R.drawable.ic_baseline_play_arrow_24),
Stopped(R.string.restart, R.drawable.ic_baseline_play_arrow_24),
Running(R.string.stop, R.drawable.pause_to_play),
}
var mainSection: View? = null
var testsPassedSection: View? = null
var testsFailedSection: View? = null
var mainSectionText: TextView? = null
var mainSectionHeader: TextView? = null
var testsPassedSectionText: TextView? = null
var testsFailedSectionText: TextView? = null
var totalProgressBar: ContentLoadingProgressBar? = null
var playPauseButton: MaterialButton? = null
var stateListener: (TestState) -> Unit = {}
private var state = TestState.None
init {
LayoutInflater.from(context).inflate(R.layout.view_test, this, true)
mainSection = findViewById(R.id.main_test_section)
testsPassedSection = findViewById(R.id.passed_test_section)
testsFailedSection = findViewById(R.id.failed_test_section)
mainSectionHeader = findViewById(R.id.main_test_header)
mainSectionText = findViewById(R.id.main_test_section_progress)
testsPassedSectionText = findViewById(R.id.passed_test_section_progress)
testsFailedSectionText = findViewById(R.id.failed_test_section_progress)
totalProgressBar = findViewById(R.id.test_total_progress)
playPauseButton = findViewById(R.id.tests_play_pause)
attrs?.let {
val typedArray = context.obtainStyledAttributes(it, R.styleable.TestView)
val headerText = typedArray.getString(R.styleable.TestView_header_text)
mainSectionHeader?.text = headerText
typedArray.recycle()
}
playPauseButton?.setOnClickListener {
val newState = when (state) {
TestState.None -> TestState.Running
TestState.Running -> TestState.Stopped
TestState.Stopped -> TestState.Running
}
setState(newState)
}
}
fun setOnPlayButtonListener(listener: (TestState) -> Unit) {
stateListener = listener
}
fun setState(newState: TestState) {
state = newState
stateListener.invoke(newState)
playPauseButton?.setText(newState.stringRes)
playPauseButton?.icon = ContextCompat.getDrawable(context, newState.icon)
}
fun setProgress(passed: Int, failed: Int, total: Int?) {
val totalProgress = passed + failed
mainSectionText?.text = "$totalProgress / ${total?.toString() ?: "?"}"
testsPassedSectionText?.text = passed.toString()
testsFailedSectionText?.text = failed.toString()
totalProgressBar?.max = (total ?: 0) * 1000
totalProgressBar?.animateProgressTo(totalProgress * 1000)
totalProgressBar?.isVisible = !(totalProgress == 0 || (total ?: 0) == 0)
if (totalProgress == total) {
setState(TestState.Stopped)
}
}
fun setMainHeader(@StringRes header: Int) {
mainSectionHeader?.setText(header)
}
fun setOnMainClick(listener: OnClickListener) {
mainSection?.setOnClickListener(listener)
}
fun setOnPassedClick(listener: OnClickListener) {
testsPassedSection?.setOnClickListener(listener)
}
fun setOnFailedClick(listener: OnClickListener) {
testsFailedSection?.setOnClickListener(listener)
}
}

View file

@ -0,0 +1,108 @@
package com.lagradost.cloudstream3.ui.settings.testing
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.TestingUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
class TestViewModel : ViewModel() {
data class TestProgress(
val passed: Int,
val failed: Int,
val total: Int
)
enum class ProviderFilter {
All,
Passed,
Failed
}
private val _providerProgress = MutableLiveData<TestProgress>(null)
val providerProgress: LiveData<TestProgress> = _providerProgress
private val _providerResults =
MutableLiveData<List<Pair<MainAPI, TestingUtils.TestResultProvider>>>(
emptyList()
)
val providerResults: LiveData<List<Pair<MainAPI, TestingUtils.TestResultProvider>>> =
_providerResults
private var scope: CoroutineScope? = null
val isRunningTest
get() = scope != null
private var filter = ProviderFilter.All
private val providers = threadSafeListOf<Pair<MainAPI, TestingUtils.TestResultProvider>>()
private var passed = 0
private var failed = 0
private var total = 0
private fun updateProgress() {
_providerProgress.postValue(TestProgress(passed, failed, total))
postProviders()
}
private fun postProviders() {
synchronized(providers) {
val filtered = when (filter) {
ProviderFilter.All -> providers
ProviderFilter.Passed -> providers.filter { it.second.success }
ProviderFilter.Failed -> providers.filter { !it.second.success }
}
_providerResults.postValue(filtered)
}
}
fun setFilterMethod(filter: ProviderFilter) {
if (this.filter == filter) return
this.filter = filter
postProviders()
}
private fun addProvider(api: MainAPI, results: TestingUtils.TestResultProvider) {
synchronized(providers) {
val index = providers.indexOfFirst { it.first == api }
if (index == -1) {
providers.add(api to results)
if (results.success) passed++ else failed++
} else {
providers[index] = api to results
}
updateProgress()
}
}
fun init() {
val apis = APIHolder.allProviders
total = apis.size
updateProgress()
}
fun startTest() {
scope = CoroutineScope(Dispatchers.Default)
val apis = APIHolder.allProviders
total = apis.size
failed = 0
passed = 0
providers.clear()
updateProgress()
TestingUtils.getDeferredProviderTests(scope ?: return, apis, ::println) { api, result ->
addProvider(api, result)
}
}
fun stopTest() {
scope?.cancel()
scope = null
}
}

View file

@ -1,5 +1,6 @@
package com.lagradost.cloudstream3.utils package com.lagradost.cloudstream3.utils
import android.animation.ObjectAnimator
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.app.Activity.RESULT_CANCELED import android.app.Activity.RESULT_CANCELED
@ -17,6 +18,7 @@ import android.os.*
import android.provider.MediaStore import android.provider.MediaStore
import android.text.Spanned import android.text.Spanned
import android.util.Log import android.util.Log
import android.view.animation.DecelerateInterpolator
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
@ -25,6 +27,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import androidx.core.text.toSpanned import androidx.core.text.toSpanned
import androidx.core.widget.ContentLoadingProgressBar
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
@ -179,6 +182,20 @@ object AppUtils {
touchSlopField.set(recyclerView, touchSlop * f) // "8" was obtained experimentally touchSlopField.set(recyclerView, touchSlop * f) // "8" was obtained experimentally
} }
fun ContentLoadingProgressBar?.animateProgressTo(to: Int) {
if (this == null) return
val animation: ObjectAnimator = ObjectAnimator.ofInt(
this,
"progress",
this.progress,
to
)
animation.duration = 500
animation.setAutoCancel(true)
animation.interpolator = DecelerateInterpolator()
animation.start()
}
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
fun getAllWatchNextPrograms(context: Context): Set<Long> { fun getAllWatchNextPrograms(context: Context): Set<Long> {
val COLUMN_WATCH_NEXT_ID_INDEX = 0 val COLUMN_WATCH_NEXT_ID_INDEX = 0

View file

@ -0,0 +1,267 @@
package com.lagradost.cloudstream3.utils
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.mvvm.logError
import kotlinx.coroutines.*
import org.junit.Assert
object TestingUtils {
open class TestResult(val success: Boolean) {
companion object {
val Pass = TestResult(true)
val Fail = TestResult(false)
}
}
class TestResultSearch(val results: List<SearchResponse>) : TestResult(true)
class TestResultLoad(val extractorData: String) : TestResult(true)
class TestResultProvider(success: Boolean, val log: String, val exception: Throwable?) :
TestResult(success)
@Throws(AssertionError::class, CancellationException::class)
suspend fun testHomepage(
api: MainAPI,
logger: (String) -> Unit
): TestResult {
if (api.hasMainPage) {
try {
val f = api.mainPage.first()
val homepage =
api.getMainPage(1, MainPageRequest(f.name, f.data, f.horizontalImages))
when {
homepage == null -> {
logger.invoke("Homepage provider ${api.name} did not correctly load homepage!")
}
homepage.items.isEmpty() -> {
logger.invoke("Homepage provider ${api.name} does not contain any items!")
}
homepage.items.any { it.list.isEmpty() } -> {
logger.invoke("Homepage provider ${api.name} does not have any items on result!")
}
}
} catch (e: Throwable) {
if (e is NotImplementedError) {
Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented")
} else if (e is CancellationException) {
throw e
}
logError(e)
}
}
return TestResult.Pass
}
@Throws(AssertionError::class, CancellationException::class)
private suspend fun testSearch(
api: MainAPI
): TestResult {
val searchQueries = listOf("over", "iron", "guy")
val searchResults = searchQueries.firstNotNullOfOrNull { query ->
try {
api.search(query).takeIf { !it.isNullOrEmpty() }
} catch (e: Throwable) {
if (e is NotImplementedError) {
Assert.fail("Provider has not implemented search()")
} else if (e is CancellationException) {
throw e
}
logError(e)
null
}
}
return if (searchResults.isNullOrEmpty()) {
Assert.fail("Api ${api.name} did not return any valid search responses")
TestResult.Fail // Should not be reached
} else {
TestResultSearch(searchResults)
}
}
@Throws(AssertionError::class, CancellationException::class)
private suspend fun testLoad(
api: MainAPI,
result: SearchResponse,
logger: (String) -> Unit
): TestResult {
try {
Assert.assertEquals(
"Invalid apiName on SearchResponse on ${api.name}",
result.apiName,
api.name
)
val loadResponse = api.load(result.url)
if (loadResponse == null) {
logger.invoke("Returned null loadResponse on ${result.url} on ${api.name}")
return TestResult.Fail
}
Assert.assertEquals(
"Invalid apiName on LoadResponse on ${api.name}",
loadResponse.apiName,
result.apiName
)
Assert.assertTrue(
"Api ${api.name} on load does not contain any of the supportedTypes: ${loadResponse.type}",
api.supportedTypes.contains(loadResponse.type)
)
val url = when (loadResponse) {
is AnimeLoadResponse -> {
val gotNoEpisodes =
loadResponse.episodes.keys.isEmpty() || loadResponse.episodes.keys.any { loadResponse.episodes[it].isNullOrEmpty() }
if (gotNoEpisodes) {
logger.invoke("Api ${api.name} got no episodes on ${loadResponse.url}")
return TestResult.Fail
}
(loadResponse.episodes[loadResponse.episodes.keys.firstOrNull()])?.firstOrNull()?.data
}
is MovieLoadResponse -> {
val gotNoEpisodes = loadResponse.dataUrl.isBlank()
if (gotNoEpisodes) {
logger.invoke("Api ${api.name} got no movie on ${loadResponse.url}")
return TestResult.Fail
}
loadResponse.dataUrl
}
is TvSeriesLoadResponse -> {
val gotNoEpisodes = loadResponse.episodes.isEmpty()
if (gotNoEpisodes) {
logger.invoke("Api ${api.name} got no episodes on ${loadResponse.url}")
return TestResult.Fail
}
loadResponse.episodes.firstOrNull()?.data
}
is LiveStreamLoadResponse -> {
loadResponse.dataUrl
}
else -> {
logger.invoke("Unknown load response: ${loadResponse.javaClass.name}")
return TestResult.Fail
}
} ?: return TestResult.Fail
return TestResultLoad(url)
// val loadTest = testLoadResponse(api, load, logger)
// if (loadTest is TestResultLoad) {
// testLinkLoading(api, loadTest.extractorData, logger).success
// } else {
// false
// }
// if (!validResults) {
// logger("Api ${api.name} did not load on the first search results: ${smallSearchResults.map { it.name }}")
// }
// return TestResult(validResults)
} catch (e: Throwable) {
if (e is NotImplementedError) {
Assert.fail("Provider has not implemented load()")
}
throw e
}
}
@Throws(AssertionError::class, CancellationException::class)
private suspend fun testLinkLoading(
api: MainAPI,
url: String?,
logger: (String) -> Unit
): TestResult {
Assert.assertNotNull("Api ${api.name} has invalid url on episode", url)
if (url == null) return TestResult.Fail // Should never trigger
var linksLoaded = 0
try {
val success = api.loadLinks(url, false, {}) { link ->
logger.invoke("Video loaded: ${link.name}")
Assert.assertTrue(
"Api ${api.name} returns link with invalid url ${link.url}",
link.url.length > 4
)
linksLoaded++
}
if (success) {
logger.invoke("Links loaded: $linksLoaded")
return TestResult(linksLoaded > 0)
} else {
Assert.fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded")
}
} catch (e: Throwable) {
when (e) {
is NotImplementedError -> {
Assert.fail("Provider has not implemented loadLinks()")
}
else -> {
logger.invoke("Failed link loading on ${api.name} using data: $url")
throw e
}
}
}
return TestResult.Pass
}
fun getDeferredProviderTests(
scope: CoroutineScope,
providers: List<MainAPI>,
logger: (String) -> Unit,
callback: (MainAPI, TestResultProvider) -> Unit
) {
providers.forEach { api ->
scope.launch {
var log = ""
fun addToLog(string: String) {
log += string + "\n"
logger.invoke(string)
}
fun getLog(): String {
return log.removeSuffix("\n")
}
val result = try {
addToLog("Trying ${api.name}")
// Test Homepage
val homepage = testHomepage(api, logger).success
Assert.assertTrue("Homepage failed to load", homepage)
// Test Search Results
val searchResults = testSearch(api)
Assert.assertTrue("Failed to get search results", searchResults.success)
searchResults as TestResultSearch
// Test Load and LoadLinks
// Only try the first 3 search results to prevent spamming
val success = searchResults.results.take(3).any { searchResponse ->
addToLog("Testing search result: ${searchResponse.url}")
val loadResponse = testLoad(api, searchResponse, ::addToLog)
if (loadResponse !is TestResultLoad) {
false
} else {
testLinkLoading(api, loadResponse.extractorData, ::addToLog).success
}
}
if (success) {
logger.invoke("Success ${api.name}")
TestResultProvider(true, getLog(), null)
} else {
logger.invoke("Error ${api.name}")
TestResultProvider(false, getLog(), null)
}
} catch (e: Throwable) {
TestResultProvider(false, getLog(), e)
}
callback.invoke(api, result)
}
}
}
}

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="?attr/white"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,14.67L3.41,6.09L2,7.5l8.5,8.5H4v2h16v-2h-6.5l5.15,-5.15C18.91,10.95 19.2,11 19.5,11c1.38,0 2.5,-1.12 2.5,-2.5S20.88,6 19.5,6S17,7.12 17,8.5c0,0.35 0.07,0.67 0.2,0.97L12,14.67z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:tint="?attr/white" android:viewportHeight="24"
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="?attr/white" android:pathData="M20.41,8.41l-4.83,-4.83C15.21,3.21 14.7,3 14.17,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V9.83C21,9.3 20.79,8.79 20.41,8.41zM7,7h7v2H7V7zM17,17H7v-2h10V17zM17,13H7v-2h10V13z"/>
</vector>

View file

@ -1,34 +1,34 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
android:orientation="vertical">
<TextView <TextView
android:id="@+id/text1" android:id="@+id/text1"
android:paddingStart="?android:attr/listPreferredItemPaddingStart" android:layout_width="match_parent"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" android:layout_height="wrap_content"
android:layout_rowWeight="1"
android:layout_marginTop="20dp" android:layout_marginTop="20dp"
android:layout_marginBottom="10dp" android:layout_marginBottom="10dp"
android:textStyle="bold" android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:textSize="20sp" android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:textColor="?attr/textColor" android:textColor="?attr/textColor"
android:layout_width="match_parent" android:textSize="20sp"
android:layout_rowWeight="1" android:textStyle="bold"
tools:text="Test" tools:text="Test" />
android:layout_height="wrap_content" />
<ListView <ListView
android:nextFocusRight="@id/cancel_btt"
android:nextFocusLeft="@id/apply_btt"
android:id="@+id/listview1" android:id="@+id/listview1"
android:layout_marginBottom="60dp"
android:paddingTop="10dp"
android:requiresFadingEdge="vertical"
tools:listitem="@layout/sort_bottom_single_choice_no_checkmark"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_rowWeight="1" /> android:layout_rowWeight="1"
android:layout_marginBottom="60dp"
android:nestedScrollingEnabled="true"
android:nextFocusLeft="@id/apply_btt"
android:nextFocusRight="@id/cancel_btt"
android:paddingTop="10dp"
android:requiresFadingEdge="vertical"
tools:listitem="@layout/sort_bottom_single_choice_no_checkmark" />
</LinearLayout> </LinearLayout>

View file

@ -129,9 +129,9 @@
<androidx.core.widget.NestedScrollView <androidx.core.widget.NestedScrollView
android:id="@+id/result_scroll" android:id="@+id/result_scroll"
android:layout_width="match_parent" android:layout_width="match_parent"
android:paddingBottom="100dp" android:layout_height="wrap_content"
android:clipToPadding="false" android:clipToPadding="false"
android:layout_height="wrap_content"> android:paddingBottom="100dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@ -326,13 +326,12 @@
<ImageView <ImageView
android:id="@+id/result_poster" android:id="@+id/result_poster"
android:layout_width="100dp" android:layout_width="100dp"
android:layout_height="140dp" android:layout_height="140dp"
android:layout_gravity="bottom"
android:contentDescription="@string/result_poster_img_des" android:contentDescription="@string/result_poster_img_des"
android:foreground="@drawable/outline_drawable" android:foreground="@drawable/outline_drawable"
android:scaleType="centerCrop" android:scaleType="centerCrop"
android:layout_gravity="bottom"
tools:src="@drawable/example_poster" /> tools:src="@drawable/example_poster" />
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>
@ -516,8 +515,8 @@
android:visibility="gone" /> android:visibility="gone" />
<com.google.android.material.chip.ChipGroup <com.google.android.material.chip.ChipGroup
style="@style/ChipParent"
android:id="@+id/result_tag" android:id="@+id/result_tag"
style="@style/ChipParent"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
<!--<com.lagradost.cloudstream3.widget.FlowLayout <!--<com.lagradost.cloudstream3.widget.FlowLayout
@ -818,10 +817,13 @@
android:layout_gravity="center_vertical" android:layout_gravity="center_vertical"
android:layout_marginStart="0dp" android:layout_marginStart="0dp"
android:layout_marginEnd="10dp" android:layout_marginEnd="10dp"
android:drawableEnd="@drawable/ic_baseline_keyboard_arrow_down_24"
android:nextFocusLeft="@id/result_episode_select" android:nextFocusLeft="@id/result_episode_select"
android:nextFocusRight="@id/result_episode_select" android:nextFocusRight="@id/result_episode_select"
android:nextFocusUp="@id/result_description" android:nextFocusUp="@id/result_description"
android:nextFocusDown="@id/result_episodes" android:nextFocusDown="@id/result_episodes"
android:paddingStart="10dp"
android:paddingEnd="5dp"
android:visibility="gone" android:visibility="gone"
tools:text="Season 1" tools:text="Season 1"
tools:visibility="visible" /> tools:visibility="visible" />
@ -829,16 +831,16 @@
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/result_episode_select" android:id="@+id/result_episode_select"
style="@style/MultiSelectButton" style="@style/MultiSelectButton"
android:layout_gravity="center_vertical" android:layout_gravity="center_vertical"
android:layout_marginStart="0dp" android:layout_marginStart="0dp"
android:layout_marginEnd="10dp" android:layout_marginEnd="10dp"
android:drawableEnd="@drawable/ic_baseline_keyboard_arrow_down_24"
android:nextFocusLeft="@id/result_season_button" android:nextFocusLeft="@id/result_season_button"
android:nextFocusRight="@id/result_season_button" android:nextFocusRight="@id/result_season_button"
android:nextFocusUp="@id/result_description" android:nextFocusUp="@id/result_description"
android:nextFocusDown="@id/result_episodes" android:nextFocusDown="@id/result_episodes"
android:paddingStart="10dp"
android:paddingEnd="5dp"
android:visibility="gone" android:visibility="gone"
tools:text="50-100" tools:text="50-100"
tools:visibility="visible" /> tools:visibility="visible" />
@ -846,15 +848,16 @@
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/result_dub_select" android:id="@+id/result_dub_select"
style="@style/MultiSelectButton" style="@style/MultiSelectButton"
android:layout_gravity="center_vertical" android:layout_gravity="center_vertical"
android:layout_marginStart="0dp" android:layout_marginStart="0dp"
android:layout_marginEnd="10dp" android:layout_marginEnd="10dp"
android:drawableEnd="@drawable/ic_baseline_keyboard_arrow_down_24"
android:nextFocusLeft="@id/result_season_button" android:nextFocusLeft="@id/result_season_button"
android:nextFocusRight="@id/result_season_button" android:nextFocusRight="@id/result_season_button"
android:nextFocusUp="@id/result_description" android:nextFocusUp="@id/result_description"
android:nextFocusDown="@id/result_episodes" android:nextFocusDown="@id/result_episodes"
android:paddingStart="10dp"
android:paddingEnd="5dp"
android:visibility="gone" android:visibility="gone"
tools:text="Dubbed" tools:text="Dubbed"
tools:visibility="visible" /> tools:visibility="visible" />

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:backgroundTint="?attr/primaryBlackBackground"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/provider_test_appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/settings_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/primaryGrayBackground"
android:paddingTop="@dimen/navbar_height"
app:layout_scrollFlags="scroll|enterAlways"
app:navigationIconTint="?attr/iconColor"
app:titleTextColor="?attr/textColor"
tools:title="@string/category_provider_test">
</com.google.android.material.appbar.MaterialToolbar>
<com.lagradost.cloudstream3.ui.settings.testing.TestView
android:id="@+id/provider_test"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nextFocusDown="@id/provider_test_recycler_view"
app:header_text="@string/category_provider_test"
app:layout_scrollFlags="scroll|enterAlways" />
</com.google.android.material.appbar.AppBarLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/provider_test_recycler_view"
android:clipToPadding="false"
android:paddingBottom="60dp"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/provider_test_item" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:nextFocusRight="@id/action_button"
android:orientation="horizontal"
android:padding="12dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/main_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?attr/textColor"
android:textSize="16sp"
tools:text="Test repository" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/lang_icon"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginEnd="5dp"
tools:text="🇷🇼"
android:textColor="?attr/grayTextColor"
tools:visibility="visible" />
<TextView
android:id="@+id/passed_failed_marker"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Failed"
tools:visibility="visible" />
</LinearLayout>
<TextView
android:id="@+id/fail_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?attr/grayTextColor"
tools:text="Unable to load videos"
tools:visibility="visible" />
</LinearLayout>
<ImageView
android:id="@+id/action_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:layout_marginStart="10dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:padding="12dp"
android:src="@drawable/baseline_text_snippet_24"
app:tint="?attr/white" />
</LinearLayout>

View file

@ -0,0 +1,138 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Widget.Material3.CardView.Elevated"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="?attr/primaryBlackBackground">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="10dp">
<LinearLayout
android:id="@+id/main_test_section"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/outline_drawable_less"
android:gravity="center"
android:orientation="horizontal"
android:paddingHorizontal="10dp"
android:paddingVertical="10dp">
<TextView
android:id="@+id/main_test_header"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="17sp"
tools:text="Homepage test" />
<TextView
android:id="@+id/main_test_section_progress"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
android:textSize="17sp"
tools:text="67 / 120 " />
</LinearLayout>
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="10dp"
app:cardBackgroundColor="?attr/primaryGrayBackground"
app:cardCornerRadius="@dimen/rounded_image_radius">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:id="@+id/passed_test_section"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/outline_drawable_less"
android:gravity="center"
android:orientation="horizontal"
android:padding="10dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Tests passed"
android:textSize="17sp" />
<TextView
android:id="@+id/passed_test_section_progress"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
android:textSize="17sp"
tools:text="55" />
</LinearLayout>
<LinearLayout
android:id="@+id/failed_test_section"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/outline_drawable_less"
android:gravity="center"
android:orientation="horizontal"
android:padding="10dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Tests failed"
android:textSize="17sp" />
<TextView
android:id="@+id/failed_test_section_progress"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
android:textSize="17sp"
tools:text="12" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<com.google.android.material.button.MaterialButton
android:id="@+id/tests_play_pause"
style="@style/WhiteButton"
android:layout_width="wrap_content"
android:layout_margin="10dp"
android:text="@string/start"
app:icon="@drawable/ic_baseline_play_arrow_24">
</com.google.android.material.button.MaterialButton>
</LinearLayout>
<androidx.core.widget.ContentLoadingProgressBar
android:id="@+id/test_total_progress"
style="@android:style/Widget.Material.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="5dp"
android:layout_gravity="bottom"
android:layout_marginBottom="-1.5dp"
android:progressBackgroundTint="?attr/colorPrimary"
android:progressTint="?attr/colorPrimary"
android:visibility="gone"
tools:progress="50" />
</androidx.cardview.widget.CardView>

View file

@ -519,6 +519,23 @@
app:popExitAnim="@anim/exit_anim" app:popExitAnim="@anim/exit_anim"
tools:layout="@layout/fragment_player" /> tools:layout="@layout/fragment_player" />
<fragment
android:id="@+id/navigation_test_providers"
android:name="com.lagradost.cloudstream3.ui.settings.testing.TestFragment"
android:layout_height="match_parent"
app:enterAnim="@anim/enter_anim"
app:exitAnim="@anim/exit_anim"
app:popEnterAnim="@anim/enter_anim"
app:popExitAnim="@anim/exit_anim"
tools:layout="@layout/fragment_testing">
<action
android:id="@+id/action_navigation_global_to_navigation_test_providers"
app:destination="@id/navigation_test_providers"
app:enterAnim="@anim/enter_anim"
app:exitAnim="@anim/exit_anim"
app:popEnterAnim="@anim/enter_anim"
app:popExitAnim="@anim/exit_anim" />
</fragment>
<fragment <fragment
android:id="@+id/navigation_setup_language" android:id="@+id/navigation_setup_language"

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<declare-styleable name="FlowLayout_Layout"> <declare-styleable name="FlowLayout_Layout">
<attr format="dimension" name="itemSpacing" /> <attr name="itemSpacing" format="dimension" />
</declare-styleable> </declare-styleable>
<declare-styleable name="FlowLayout_Layout_layout_space" /> <declare-styleable name="FlowLayout_Layout_layout_space" />
@ -13,6 +13,10 @@
<item name="customCastBackgroundColor">?attr/colorPrimary</item> <item name="customCastBackgroundColor">?attr/colorPrimary</item>
</style> </style>
<declare-styleable name="TestView">
<attr name="header_text" format="string" />
</declare-styleable>
<declare-styleable name="MainColors"> <declare-styleable name="MainColors">
<attr name="colorPrimary" format="color" /> <attr name="colorPrimary" format="color" />
<attr name="colorSearch" format="color" /> <attr name="colorSearch" format="color" />

View file

@ -82,4 +82,7 @@
<color name="colorPrimaryGrey">#515151</color> <color name="colorPrimaryGrey">#515151</color>
<color name="colorPrimaryWhite">#FFFFFF</color> <color name="colorPrimaryWhite">#FFFFFF</color>
<color name="colorPrimaryBrown">#622C00</color> <color name="colorPrimaryBrown">#622C00</color>
<color name="colorTestPass">#48E484</color>
<color name="colorTestFail">#ea596e</color>
</resources> </resources>

View file

@ -13,6 +13,7 @@
<string name="fast_forward_button_time_key" translatable="false">fast_forward_button_time</string> <string name="fast_forward_button_time_key" translatable="false">fast_forward_button_time</string>
<string name="benene_count" translatable="false">benene_count</string> <string name="benene_count" translatable="false">benene_count</string>
<string name="subtitle_settings_key" translatable="false">subtitle_settings_key</string> <string name="subtitle_settings_key" translatable="false">subtitle_settings_key</string>
<string name="test_providers_key" translatable="false">test_providers_key</string>
<string name="subtitle_settings_chromecast_key" translatable="false">subtitle_settings_chromecast_key</string> <string name="subtitle_settings_chromecast_key" translatable="false">subtitle_settings_chromecast_key</string>
<string name="quality_pref_key" translatable="false">quality_pref_key</string> <string name="quality_pref_key" translatable="false">quality_pref_key</string>
<string name="player_pref_key" translatable="false">player_pref_key</string> <string name="player_pref_key" translatable="false">player_pref_key</string>
@ -195,6 +196,7 @@
<string name="normal_no_plot">No Plot Found</string> <string name="normal_no_plot">No Plot Found</string>
<string name="torrent_no_plot">No Description Found</string> <string name="torrent_no_plot">No Description Found</string>
<string name="show_log_cat">Show Logcat 🐈</string> <string name="show_log_cat">Show Logcat 🐈</string>
<string name="test_log">Log</string>
<string name="picture_in_picture">Picture-in-picture</string> <string name="picture_in_picture">Picture-in-picture</string>
<string name="picture_in_picture_des">Continues playback in a miniature player on top of other apps</string> <string name="picture_in_picture_des">Continues playback in a miniature player on top of other apps</string>
<string name="player_size_settings">Player resize button</string> <string name="player_size_settings">Player resize button</string>
@ -282,6 +284,9 @@
<string name="delete">Delete</string> <string name="delete">Delete</string>
<string name="cancel" translatable="false">@string/sort_cancel</string> <string name="cancel" translatable="false">@string/sort_cancel</string>
<string name="pause">Pause</string> <string name="pause">Pause</string>
<string name="start">Start</string>
<string name="test_failed">Failed</string>
<string name="test_passed">Passed</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>
@ -423,6 +428,7 @@
<string name="enable_nsfw_on_providers">Enable NSFW on supported providers</string> <string name="enable_nsfw_on_providers">Enable NSFW on supported providers</string>
<string name="subtitles_encoding">Subtitle encoding</string> <string name="subtitles_encoding">Subtitle encoding</string>
<string name="category_providers">Providers</string> <string name="category_providers">Providers</string>
<string name="category_provider_test">Provider test</string>
<string name="category_ui">Layout</string> <string name="category_ui">Layout</string>
<string name="automatic">Auto</string> <string name="automatic">Auto</string>
<string name="tv_layout">TV layout</string> <string name="tv_layout">TV layout</string>
@ -579,6 +585,8 @@
<string name="audio_tracks">Audio tracks</string> <string name="audio_tracks">Audio tracks</string>
<string name="video_tracks">Video tracks</string> <string name="video_tracks">Video tracks</string>
<string name="apply_on_restart">Apply on Restart</string> <string name="apply_on_restart">Apply on Restart</string>
<string name="restart">Restart</string>
<string name="stop">Stop</string>
<string name="safe_mode_title">Safe mode on</string> <string name="safe_mode_title">Safe mode on</string>
<string name="safe_mode_description">All extensions were turned off due to a crash to help you find the one causing trouble.</string> <string name="safe_mode_description">All extensions were turned off due to a crash to help you find the one causing trouble.</string>
<string name="safe_mode_crash_info">View crash info</string> <string name="safe_mode_crash_info">View crash info</string>

View file

@ -7,18 +7,24 @@
android:title="@string/provider_lang_settings" /> android:title="@string/provider_lang_settings" />
<Preference <Preference
android:icon="@drawable/ic_baseline_play_arrow_24"
android:key="@string/prefer_media_type_key" android:key="@string/prefer_media_type_key"
android:title="@string/preferred_media_settings" android:title="@string/preferred_media_settings" />
android:icon="@drawable/ic_baseline_play_arrow_24" />
<Preference <Preference
android:icon="@drawable/ic_outline_voice_over_off_24"
android:key="@string/display_sub_key" android:key="@string/display_sub_key"
android:title="@string/display_subbed_dubbed_settings" android:title="@string/display_subbed_dubbed_settings" />
android:icon="@drawable/ic_outline_voice_over_off_24" />
<SwitchPreference <SwitchPreference
android:key="@string/enable_nsfw_on_providers_key"
android:title="@string/enable_nsfw_on_providers"
android:icon="@drawable/ic_baseline_extension_24" android:icon="@drawable/ic_baseline_extension_24"
android:key="@string/enable_nsfw_on_providers_key"
android:summary="@string/apply_on_restart" android:summary="@string/apply_on_restart"
android:title="@string/enable_nsfw_on_providers"
app:defaultValue="false" /> app:defaultValue="false" />
<Preference
android:icon="@drawable/baseline_network_ping_24"
android:key="@string/test_providers_key"
android:title="Test all providers" />
</PreferenceScreen> </PreferenceScreen>