diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt index 81753f6b..92042d60 100644 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt @@ -1,9 +1,8 @@ package com.lagradost.cloudstream3 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.TestingUtils import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Test @@ -16,142 +15,11 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) 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 { + println("Providers: ${APIHolder.allProviders.size}") 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? = 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 fun providersExist() { Assert.assertTrue(getAllProviders().isNotEmpty()) @@ -159,6 +27,7 @@ class ExampleInstrumentedTest { } @Test + @Throws(AssertionError::class) fun providerCorrectData() { val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 } Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty()) @@ -181,67 +50,20 @@ class ExampleInstrumentedTest { fun providerCorrectHomepage() { runBlocking { getAllProviders().amap { api -> - if (api.hasMainPage) { - 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) - } - } + TestingUtils.testHomepage(api, ::println) } } println("Done providerCorrectHomepage") } -// @Test -// fun testSingleProvider() { -// testSingleProviderApi(ThenosProvider()) -// } - @Test - fun providerCorrect() { + fun testAllProvidersCorrect() { runBlocking { - val invalidProvider = ArrayList>() - val providers = getAllProviders() - providers.amap { api -> - try { - 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}") - } - } + TestingUtils.getDeferredProviderTests( + this, + getAllProviders(), + ::println + ) { _, _ -> } } - println("Done providerCorrect") } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt index a277f622..3958984e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt @@ -17,8 +17,10 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.Coroutines.mainWork import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import okhttp3.Interceptor +import org.mozilla.javascript.Scriptable import java.text.SimpleDateFormat import java.util.* import kotlin.math.absoluteValue @@ -734,6 +736,19 @@ fun fixTitle(str: String): String { .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 */ fun imdbUrlToId(url: String): String? { diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index eddec15e..28419e7a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -402,6 +402,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { R.id.navigation_settings_general, R.id.navigation_settings_extensions, R.id.navigation_settings_plugins, + R.id.navigation_test_providers, ).contains(destination.id) diff --git a/app/src/main/java/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt b/app/src/main/java/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt index afe956cc..bb15bc85 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt @@ -49,7 +49,7 @@ inline fun debugWarning(assert: () -> Boolean, message: () -> String) { } } -fun LifecycleOwner.observe(liveData: LiveData, action: (t: T) -> Unit) { +fun LifecycleOwner.observe(liveData: LiveData, action: (t: T) -> Unit) { liveData.observe(this) { it?.let { t -> action(t) } } } @@ -121,13 +121,21 @@ suspend fun suspendSafeApiCall(apiCall: suspend () -> T): T? { } } +fun Throwable.getAllMessages(): String { + return (this.localizedMessage ?: "") + (this.cause?.getAllMessages()?.let { "\n$it" } ?: "") +} + +fun Throwable.getStackTracePretty(showMessage: Boolean = true): String { + val prefix = if (showMessage) this.localizedMessage?.let { "\n$it" } ?: "" else "" + return prefix + this.stackTrace.joinToString( + separator = "\n" + ) { + "${it.fileName} ${it.lineNumber}" + } +} + fun safeFail(throwable: Throwable): Resource { - val stackTraceMsg = - (throwable.localizedMessage ?: "") + "\n\n" + throwable.stackTrace.joinToString( - separator = "\n" - ) { - "${it.fileName} ${it.lineNumber}" - } + val stackTraceMsg = throwable.getStackTracePretty() return Resource.Failure(false, null, null, stackTraceMsg) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt index 3b01508d..42a864a6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt @@ -2,6 +2,8 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.view.View +import androidx.navigation.NavOptions +import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager 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.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard +import com.lagradost.cloudstream3.utils.UIHelper.navigate class SettingsProviders : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -56,6 +59,20 @@ class SettingsProviders : PreferenceFragmentCompat() { 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 { val names = enumValues().sorted().map { it.name } val default = diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt new file mode 100644 index 00000000..34cd67cd --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt new file mode 100644 index 00000000..d04e2379 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt @@ -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>) : + AppUtils.DiffAdapter>(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() + } + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt new file mode 100644 index 00000000..26513f4a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt new file mode 100644 index 00000000..2e05baff --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt @@ -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(null) + val providerProgress: LiveData = _providerProgress + + private val _providerResults = + MutableLiveData>>( + emptyList() + ) + + val providerResults: LiveData>> = + _providerResults + + private var scope: CoroutineScope? = null + val isRunningTest + get() = scope != null + + private var filter = ProviderFilter.All + private val providers = threadSafeListOf>() + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt index 00dee9b2..4b1053b1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.utils +import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.app.Activity import android.app.Activity.RESULT_CANCELED @@ -17,6 +18,7 @@ import android.os.* import android.provider.MediaStore import android.text.Spanned import android.util.Log +import android.view.animation.DecelerateInterpolator import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi @@ -25,6 +27,7 @@ import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.text.HtmlCompat import androidx.core.text.toSpanned +import androidx.core.widget.ContentLoadingProgressBar import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.navigation.fragment.findNavController @@ -179,6 +182,20 @@ object AppUtils { 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") fun getAllWatchNextPrograms(context: Context): Set { val COLUMN_WATCH_NEXT_ID_INDEX = 0 diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt new file mode 100644 index 00000000..66e1e504 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt @@ -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) : 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, + 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_network_ping_24.xml b/app/src/main/res/drawable/baseline_network_ping_24.xml new file mode 100644 index 00000000..1caae667 --- /dev/null +++ b/app/src/main/res/drawable/baseline_network_ping_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/baseline_text_snippet_24.xml b/app/src/main/res/drawable/baseline_text_snippet_24.xml new file mode 100644 index 00000000..c1f3654b --- /dev/null +++ b/app/src/main/res/drawable/baseline_text_snippet_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/fragment_testing.xml b/app/src/main/res/layout/fragment_testing.xml new file mode 100644 index 00000000..1426f59e --- /dev/null +++ b/app/src/main/res/layout/fragment_testing.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/provider_test_item.xml b/app/src/main/res/layout/provider_test_item.xml new file mode 100644 index 00000000..065b1bd8 --- /dev/null +++ b/app/src/main/res/layout/provider_test_item.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index d71eeb06..e59f670e 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -519,6 +519,23 @@ app:popExitAnim="@anim/exit_anim" tools:layout="@layout/fragment_player" /> + + + - + @@ -13,6 +13,10 @@ ?attr/colorPrimary + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 61ff0c2b..7dd4c989 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -82,4 +82,7 @@ #515151 #FFFFFF #622C00 + + #48E484 + #ea596e \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 778f34c9..cb9d5508 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,6 +13,7 @@ fast_forward_button_time benene_count subtitle_settings_key + test_providers_key subtitle_settings_chromecast_key quality_pref_key player_pref_key @@ -195,6 +196,7 @@ No Plot Found No Description Found Show Logcat 🐈 + Log Picture-in-picture Continues playback in a miniature player on top of other apps Player resize button @@ -282,6 +284,9 @@ Delete @string/sort_cancel Pause + Start + Failed + Passed Resume -30 +30 @@ -423,6 +428,7 @@ Enable NSFW on supported providers Subtitle encoding Providers + Provider test Layout Auto TV layout @@ -579,6 +585,8 @@ Audio tracks Video tracks Apply on Restart + Restart + Stop Safe mode on All extensions were turned off due to a crash to help you find the one causing trouble. View crash info @@ -636,4 +644,6 @@ Looks like your library is empty :(\nLogin to a library account or add shows to your local library Looks like this list is empty, try switching to another one Safe mode file found!\nNot loading any extensions on startup until file is removed. + + Hello blank fragment \ No newline at end of file diff --git a/app/src/main/res/xml/settings_providers.xml b/app/src/main/res/xml/settings_providers.xml index a177865b..1ee58faf 100644 --- a/app/src/main/res/xml/settings_providers.xml +++ b/app/src/main/res/xml/settings_providers.xml @@ -1,24 +1,30 @@ + xmlns:app="http://schemas.android.com/apk/res-auto"> + android:icon="@drawable/ic_baseline_language_24" + android:key="@string/provider_lang_key" + android:title="@string/provider_lang_settings" /> + android:icon="@drawable/ic_baseline_play_arrow_24" + android:key="@string/prefer_media_type_key" + android:title="@string/preferred_media_settings" /> + android:icon="@drawable/ic_outline_voice_over_off_24" + android:key="@string/display_sub_key" + android:title="@string/display_subbed_dubbed_settings" /> + android:icon="@drawable/ic_baseline_extension_24" + android:key="@string/enable_nsfw_on_providers_key" + android:summary="@string/apply_on_restart" + android:title="@string/enable_nsfw_on_providers" + app:defaultValue="false" /> + + + \ No newline at end of file