From 69443b996f1fd94488f3d434375b3238d3e616d4 Mon Sep 17 00:00:00 2001 From: CloudStream User Date: Wed, 3 Jun 2026 14:31:51 +0530 Subject: [PATCH 1/5] CloudStream Mega Update: Reliable Disable, Multi-Select, Smart Sources, Folders, Auto-Maintenance, Unified Progress, and VPN Check --- README.md | 21 +-- .../lagradost/cloudstream3/MainActivity.kt | 2 + .../cloudstream3/plugins/PluginManager.kt | 44 +++++- .../services/MaintenanceWorkManager.kt | 75 ++++++++++ .../cloudstream3/ui/player/GeneratorPlayer.kt | 5 + .../ui/player/PlayerGeneratorViewModel.kt | 12 +- .../settings/extensions/ExtensionsFragment.kt | 46 +++++++ .../extensions/ExtensionsViewModel.kt | 18 ++- .../ui/settings/extensions/PluginAdapter.kt | 58 ++++++-- .../ui/settings/extensions/PluginsFragment.kt | 81 +++++++++-- .../settings/extensions/PluginsViewModel.kt | 129 +++++++++++++++++- .../ui/settings/testing/TestFragment.kt | 42 ++++++ .../ui/settings/testing/TestResultAdapter.kt | 11 +- .../ui/settings/testing/TestView.kt | 33 ++++- .../ui/settings/testing/TestViewModel.kt | 68 +++++++++ .../cloudstream3/utils/DataStoreHelper.kt | 60 ++++++-- .../cloudstream3/utils/TestingUtils.kt | 13 +- .../drawable/ic_baseline_check_circle_24.xml | 10 ++ .../ic_baseline_radio_button_unchecked_24.xml | 10 ++ .../main/res/layout/fragment_extensions.xml | 111 +++++++++------ app/src/main/res/layout/fragment_plugins.xml | 14 ++ app/src/main/res/layout/view_test.xml | 63 +++++++-- app/src/main/res/menu/extensions_menu.xml | 14 ++ app/src/main/res/menu/plugin_selection.xml | 29 ++++ app/src/main/res/values/strings.xml | 9 ++ gradle/gradle-daemon-jvm.properties | 12 ++ 26 files changed, 876 insertions(+), 114 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/services/MaintenanceWorkManager.kt create mode 100644 app/src/main/res/drawable/ic_baseline_check_circle_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_radio_button_unchecked_24.xml create mode 100644 app/src/main/res/menu/extensions_menu.xml create mode 100644 app/src/main/res/menu/plugin_selection.xml create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/README.md b/README.md index c2492c5d821..5d38df1621e 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ + [Contributing:](#contributing) + [Issues:](#issues) + [Bugs Reports:](#bug_report) - + [Enhancement:](#enhancment) + + [Enhancement:](#enhancement) + [Extension Development:](#extensions) + [Language Support:](#languages) + [Further Sources](#contact_and_sources) - + ## About us: @@ -49,7 +49,7 @@ Our documentation is unmaintained and open to contributions; therefore, apps and + Extension system for personal customization - + ## Installation: @@ -57,37 +57,38 @@ Our documentation provides the steps to install and configure CloudStream for yo [Getting Started With CloudStream:](https://recloudstream.github.io/csdocs/) - + ## Contributing: -We **happily** accept any contributions to our project. To find out where you can start contributing towards the project, please look [at our issues tab](/cloudstream/issues) +We **happily** accept any contributions to our project. To find out where you can start contributing towards the project, please look [at our issues tab](https://github.com/recloudstream/cloudstream/issues) - + ### Issues: While we **actively** accept issues and pull requests, we do require you fill out an [template](https://github.com/recloudstream/cloudstream/issues/new/choose) for issues. These include the following: - + - [Bug Report Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=bug&projects=&template=application-bug.yml) - For bug reports, we want as much info as possible, including your downloaded version of CloudeStream, device and updated version (if possible, current API), expected behavior of the program, and the actual behavior that the program did, most importantly we require clear, reproducible steps of the bug. If your bug can't be reproduced, it is unlikely we'll work on your issue. - + - [Feature Request Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=enhancement&projects=&template=feature-request.yml) - Before adding a feature request, please check to see if a feature request already has been requested. ### Extensions: + **Further details on creating extensions for CloudStream are found in our documentation.** [Guide: For Extension Developers](https://recloudstream.github.io/csdocs/devs/gettingstarted/) - + ## Further Sources: @@ -100,7 +101,7 @@ As well as providing clear install steps, our [website](https://dweb.link/ipns/c - [Linux](https://recloudstream.github.io/csdocs/other-devices/linux/) - And more... - + ### Supported languages: diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 90583011d19..8f672553370 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -91,6 +91,7 @@ import com.lagradost.cloudstream3.plugins.PluginManager.___DO_NOT_CALL_FROM_A_PL import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver import com.lagradost.cloudstream3.services.SubscriptionWorkManager +import com.lagradost.cloudstream3.services.MaintenanceWorkManager import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_PLAYER @@ -1197,6 +1198,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa setNavigationBarColorCompat(R.attr.primaryGrayBackground) updateLocale() super.onCreate(savedInstanceState) + MaintenanceWorkManager.enqueuePeriodicWork(this) try { if (isCastApiAvailable()) { CastContext.getSharedInstance(this) { it.run() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index debd3f0ebbd..32c7e263b05 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -67,6 +67,7 @@ import java.io.InputStreamReader // Different keys for local and not since local can be removed at any time without app knowing, hence the local are getting rebuilt on every app start const val PLUGINS_KEY = "PLUGINS_KEY" const val PLUGINS_KEY_LOCAL = "PLUGINS_KEY_LOCAL" +const val PLUGINS_DISABLED_KEY = "PLUGINS_DISABLED_KEY" const val EXTENSIONS_CHANNEL_ID = "cloudstream3.extensions" const val EXTENSIONS_CHANNEL_NAME = "Extensions" @@ -180,6 +181,23 @@ object PluginManager { return getKey(PLUGINS_KEY_LOCAL) ?: emptyArray() } + fun getDisabledPlugins(): Set { + return getKey>(PLUGINS_DISABLED_KEY)?.toSet() ?: emptySet() + } + + fun setPluginDisabled(path: String, disabled: Boolean) { + val current = getDisabledPlugins().toMutableSet() + if (disabled) { + current.add(path) + unloadPlugin(path) + } else { + current.remove(path) + // Note: Enabling requires reloading which might need context. + // For now, we just update the persistent list. + } + setKey(PLUGINS_DISABLED_KEY, current.toTypedArray()) + } + private val CLOUD_STREAM_FOLDER = Environment.getExternalStorageDirectory().absolutePath + "/Cloudstream3/" @@ -255,6 +273,17 @@ object PluginManager { } ?: false } + suspend fun loadSinglePluginByPath(context: Context, path: String): Boolean { + return (getPluginsOnline().firstOrNull { it.filePath == path } + ?: getPluginsLocal().firstOrNull { it.filePath == path })?.let { savedData -> + loadPlugin( + context, + File(savedData.filePath), + savedData + ) + } ?: false + } + /** * Needs to be run before other plugin loading because plugin loading can not be overwritten * 1. Gets all online data about the downloaded plugins @@ -460,13 +489,16 @@ object PluginManager { suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) { assertNonRecursiveCallstack() + val disabled = getDisabledPlugins() // Load all plugins as fast as possible! (getPluginsOnline()).toList().amap { pluginData -> - loadPlugin( - context, - File(pluginData.filePath), - pluginData - ) + if (pluginData.filePath !in disabled) { + loadPlugin( + context, + File(pluginData.filePath), + pluginData + ) + } } } @@ -529,9 +561,11 @@ object PluginManager { // Make sure all local plugins are fully refreshed. removeKey(PLUGINS_KEY_LOCAL) + val disabled = getDisabledPlugins() sortedPlugins?.sortedBy { it.name }?.amap { file -> try { val destinationFile = File(pluginDirectory, file.name) + if (destinationFile.absolutePath in disabled) return@amap // Only copy the file if the destination file doesn't exist or if it // has been modified (check file length and modification time). diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/MaintenanceWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/MaintenanceWorkManager.kt new file mode 100644 index 00000000000..fb9713f8138 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/services/MaintenanceWorkManager.kt @@ -0,0 +1,75 @@ +package com.lagradost.cloudstream3.services + +import android.content.Context +import androidx.work.* +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.utils.TestingUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import java.util.concurrent.TimeUnit + +class MaintenanceWorkManager(val context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + companion object { + const val MAINTENANCE_WORK_NAME = "work_maintenance" + + fun enqueuePeriodicWork(context: Context?) { + if (context == null) return + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .build() + + val periodicWork = + PeriodicWorkRequest.Builder(MaintenanceWorkManager::class.java, 24, TimeUnit.HOURS) + .addTag(MAINTENANCE_WORK_NAME) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + MAINTENANCE_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + periodicWork + ) + } + } + + override suspend fun doWork(): Result { + try { + // Load all plugins + PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context) + PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context, false) + + val apis = APIHolder.allProviders.withLock { APIHolder.allProviders.toTypedArray() } + + // Map to track successes per plugin path + val pluginSuccess = mutableMapOf() + val pluginPaths = apis.mapNotNull { it.sourcePlugin }.distinct() + pluginPaths.forEach { pluginSuccess[it] = false } + + val scope = CoroutineScope(Dispatchers.Default + kotlinx.coroutines.Job()) + TestingUtils.getDeferredProviderTests(scope, apis) { api, result -> + val path = api.sourcePlugin ?: return@getDeferredProviderTests + if (result.success) { + pluginSuccess[path] = true + } + } + + // Wait for tests to finish + scope.coroutineContext[kotlinx.coroutines.Job]?.children?.forEach { it.join() } + + pluginSuccess.forEach { (path, success) -> + if (!success) { + // All providers in this plugin failed + PluginManager.setPluginDisabled(path, true) + } + } + + return Result.success() + } catch (t: Throwable) { + return Result.failure() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 17bef3ec07a..54e55bdf9c2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -1350,6 +1350,11 @@ class GeneratorPlayer : FullScreenPlayer() { } if (init) { sortedUrls.getOrNull(sourceIndex)?.let { + it.first?.source?.let { source -> + viewModel.state.generatorState?.id?.let { parentId -> + DataStoreHelper.setSourcePreference(parentId, source) + } + } loadLink(it, true) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt index e3c390d504c..43832e513dc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt @@ -15,6 +15,7 @@ import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.videoskip.SkipAPI import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp import kotlinx.collections.immutable.PersistentList @@ -66,9 +67,16 @@ data class VideoState( * Use .links if order is not needed */ @Contract(pure = true) fun sortLinks(qualityProfile: Int): List { - return sortedLinks[qualityProfile] ?: links.sortedBy { link -> + val preferredSource = generatorState?.id?.let { DataStoreHelper.getSourcePreference(it) } + return sortedLinks[qualityProfile] ?: links.sortedWith { a, b -> + if (preferredSource != null) { + val aPreferred = a.first?.source == preferredSource + val bPreferred = b.first?.source == preferredSource + if (aPreferred && !bPreferred) return@sortedWith -1 + if (!aPreferred && bPreferred) return@sortedWith 1 + } // negative because we want to sort highest quality first - -getLinkPriority(qualityProfile, link.first) + compareValuesBy(b, a) { getLinkPriority(qualityProfile, it.first) } }.also { value -> sortedLinks[qualityProfile] = value } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt index af0d3dfe756..f3e7a25e35e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt @@ -9,6 +9,7 @@ import android.view.View import android.widget.LinearLayout import android.widget.Toast import androidx.appcompat.app.AlertDialog +import com.google.android.material.appbar.MaterialToolbar import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.marginBottom @@ -35,6 +36,7 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.addRepositoryDialog import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.setText @@ -76,6 +78,39 @@ class ExtensionsFragment : BaseFragment( setUpToolbar(R.string.extensions) setToolBarScrollFlags() + val settingsToolbar = view?.findViewById(R.id.settings_toolbar) + settingsToolbar?.inflateMenu(R.menu.extensions_menu) + settingsToolbar?.setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_sync_all -> { + extensionViewModel.syncAllRepositories() + showToast(R.string.sync_all_repos_success, Toast.LENGTH_SHORT) + true + } + R.id.action_add_folder -> { + val ctx = context ?: return@setOnMenuItemClickListener false + val builder = AlertDialog.Builder(ctx, R.style.AlertDialogCustom) + builder.setTitle(R.string.add_folder) + val input = android.widget.EditText(ctx) + input.hint = ctx.getString(R.string.folder_name) + builder.setView(input) + builder.setPositiveButton(R.string.ok) { _, _ -> + val name = input.text.toString() + if (name.isNotBlank()) { + val folders = DataStoreHelper.getExtensionFolders().toMutableMap() + folders[name] = emptyList() + DataStoreHelper.setExtensionFolders(folders) + showToast("Folder $name created", Toast.LENGTH_SHORT) + } + } + builder.setNegativeButton(R.string.cancel, null) + builder.show().setDefaultFocus() + true + } + else -> false + } + } + binding.repoRecyclerView.apply { setLinearListLayout( isHorizontal = false, @@ -185,6 +220,17 @@ class ExtensionsFragment : BaseFragment( ) } + binding.pluginDisabledHolder.setOnClickListener { + findNavController().navigate( + R.id.navigation_settings_extensions_to_navigation_settings_plugins, + PluginsFragment.newInstance( + getString(R.string.plugins_disabled_title), + "disabled", + true + ) + ) + } + val addRepositoryClick = View.OnClickListener { val ctx = context ?: return@OnClickListener val binding = AddRepoInputBinding.inflate(LayoutInflater.from(ctx), null, false) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt index 482251b7831..e5a45e9696c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt @@ -15,6 +15,7 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIE import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.DataStoreHelper data class RepositoryData( @JsonProperty("iconUrl") val iconUrl: String?, @@ -87,7 +88,20 @@ class ExtensionsViewModel : ViewModel() { ?: emptyArray()) + PREBUILT_REPOSITORIES fun loadRepositories() { - val urls = repos() - _repositories.postValue(urls) + val urls = repos().toMutableList() + val folders = DataStoreHelper.getExtensionFolders() + folders.forEach { entry -> + urls.add(RepositoryData(null, entry.key, "folder://${entry.key}")) + } + _repositories.postValue(urls.toTypedArray()) + } + + fun syncAllRepositories() = ioSafe { + val repos = repos().toList() + repos.amap { repo -> + RepositoryManager.getRepoPlugins(repo.url) + } + loadStats() + loadRepositories() } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt index d0f9ff565da..3923fb6f1e8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt @@ -6,6 +6,7 @@ import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.viewbinding.ViewBinding @@ -36,6 +37,9 @@ import kotlin.math.pow data class PluginViewData( val plugin: Plugin, val isDownloaded: Boolean, + val isLocalDisabled: Boolean = false, + val isSelected: Boolean = false, + val isInSelectionMode: Boolean = false, ) class RepositoryViewHolderState(view: ViewBinding) : ViewHolderState(view) { @@ -44,10 +48,12 @@ class RepositoryViewHolderState(view: ViewBinding) : ViewHolderState(view) } class PluginAdapter( - val iconClickCallback: (Plugin) -> Unit + val iconClickCallback: (Plugin) -> Unit, + val longClickCallback: (Plugin) -> Unit = {}, + val clickCallback: (Plugin) -> Unit = {}, ) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a.plugin.second.internalName == b.plugin.second.internalName && a.plugin.first == b.plugin.first -})) { +}, contentSame = { a, b -> a == b })) { override fun onCreateContent(parent: ViewGroup): ViewHolderState { val layout = if (isLayout(TV)) R.layout.repository_item_tv else R.layout.repository_item val inflated = LayoutInflater.from(parent.context).inflate(layout, parent, false) @@ -74,16 +80,17 @@ class PluginAdapter( val itemView = holder.itemView val metadata = item.plugin.second - val disabled = metadata.status == PROVIDER_STATUS_DOWN + val disabled = metadata.status == PROVIDER_STATUS_DOWN || item.isLocalDisabled val name = metadata.name.removeSuffix("Provider") val alpha = if (disabled) 0.6f else 1f val isLocal = !item.plugin.second.url.startsWith("http") binding.mainText.alpha = alpha binding.subText.alpha = alpha - val drawableInt = if (item.isDownloaded) - R.drawable.ic_baseline_delete_outline_24 - else R.drawable.netflix_download + val drawableInt = if (item.isDownloaded) { + if (item.isLocalDisabled) R.drawable.ic_baseline_play_arrow_24 + else R.drawable.ic_baseline_delete_outline_24 + } else R.drawable.netflix_download binding.nsfwMarker.isVisible = metadata.tvTypes?.contains(TvType.NSFW.name) ?: false binding.actionButton.setImageResource(drawableInt) @@ -92,20 +99,30 @@ class PluginAdapter( iconClickCallback.invoke(item.plugin) } itemView.setOnClickListener { + if (item.isInSelectionMode) { + clickCallback.invoke(item.plugin) + return@setOnClickListener + } if (isLocal) return@setOnClickListener val sheet = PluginDetailsFragment(item) val activity = itemView.context.getActivity() as AppCompatActivity sheet.show(activity.supportFragmentManager, "PluginDetails") } - //if (itemView.context?.isTrueTvSettings() == false) { - // val siteUrl = metadata.repositoryUrl - // if (siteUrl != null && siteUrl.isNotBlank() && siteUrl != "NONE") { - // itemView.setOnClickListener { - // openBrowser(siteUrl) - // } - // } - //} + itemView.setOnLongClickListener { + longClickCallback.invoke(item.plugin) + true + } + + if (item.isInSelectionMode) { + binding.actionButton.setImageResource( + if (item.isSelected) R.drawable.ic_baseline_check_circle_24 + else R.drawable.ic_baseline_radio_button_unchecked_24 + ) + binding.actionButton.isVisible = true + } else { + binding.actionButton.setImageResource(drawableInt) + } if (item.isDownloaded) { // On local plugins page the filepath is provided instead of url. @@ -194,6 +211,19 @@ class PluginAdapter( ) else txt(name) ) + // Health Badge + val healthColor = when { + disabled -> R.color.colorTestFail + metadata.status == 2 -> R.color.colorTestWarning // Status 2 for unstable + else -> R.color.colorTestPass + } + val circle = ContextCompat.getDrawable(itemView.context, R.drawable.ic_baseline_check_circle_24)?.mutate()?.apply { + setTint(ContextCompat.getColor(itemView.context, healthColor)) + setBounds(0, 0, 10.toPx, 10.toPx) + } + binding.mainText.setCompoundDrawablesWithIntrinsicBounds(null, null, circle, null) + binding.mainText.compoundDrawablePadding = 5.toPx + binding.subText.isGone = metadata.description.isNullOrBlank() binding.subText.text = metadata.description.html() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt index 534ffa62a43..31e9cead202 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt @@ -23,6 +23,9 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSyst import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.toPx @@ -160,14 +163,18 @@ class PluginsFragment : BaseFragment( ) setRecycledViewPool(PluginAdapter.sharedPool) adapter = - PluginAdapter { - pluginViewModel.handlePluginAction(activity, url, it, isLocal) - } - } - - if (isLayout(TV or EMULATOR)) { - // Scrolling down does not reveal the whole RecyclerView on TV, add to bypass that. - binding.pluginRecyclerView.setPadding(0, 0, 0, 200.toPx) + PluginAdapter( + iconClickCallback = { + pluginViewModel.handlePluginAction(activity, url, it, isLocal) + }, + longClickCallback = { + pluginViewModel.toggleSelectionMode(true) + pluginViewModel.toggleSelection(it.second.url) + }, + clickCallback = { + pluginViewModel.toggleSelection(it.second.url) + } + ) } observe(pluginViewModel.filteredPlugins) { (scrollToTop, list) -> @@ -175,13 +182,69 @@ class PluginsFragment : BaseFragment( if (scrollToTop) { binding.pluginRecyclerView.scrollToPosition(0) } + + val selectedCount = list.count { it.isSelected && it.isInSelectionMode } + binding.selectionToolbar.apply { + isVisible = list.any { it.isInSelectionMode } + binding.settingsToolbar.isVisible = !isVisible + title = "$selectedCount Selected" + } + } + + binding.selectionToolbar.apply { + inflateMenu(R.menu.plugin_selection) + setNavigationOnClickListener { + pluginViewModel.toggleSelectionMode(false) + } + setOnMenuItemClickListener { menuItem -> + val action = when (menuItem.itemId) { + R.id.action_batch_download -> PluginsViewModel.BatchAction.Download + R.id.action_batch_enable -> PluginsViewModel.BatchAction.Enable + R.id.action_batch_disable -> PluginsViewModel.BatchAction.Disable + R.id.action_batch_delete -> PluginsViewModel.BatchAction.Delete + R.id.action_batch_move -> { + val folders = DataStoreHelper.getExtensionFolders() + if (folders.isEmpty()) { + showToast("Create a folder first in the Extensions screen") + } else { + val names = folders.keys.toList() + activity?.showDialog( + names, + -1, + "Move to Folder", + true, + {} + ) { index: Int -> + val folderName = names[index] + val selected = pluginViewModel.selectedPlugins.toList() + val currentFolders = DataStoreHelper.getExtensionFolders().toMutableMap() + val currentList = currentFolders[folderName]?.toMutableList() ?: mutableListOf() + currentList.addAll(selected) + currentFolders[folderName] = currentList.distinct() + DataStoreHelper.setExtensionFolders(currentFolders) + showToast("Moved to $folderName") + pluginViewModel.toggleSelectionMode(false) + } + } + null + } + else -> null + } + action?.let { + pluginViewModel.batchAction(activity, it) + } + true + } } if (isLocal) { // No download button and no categories on local downloadAllButton?.isVisible = false binding.settingsToolbar.menu?.findItem(R.id.lang_filter)?.isVisible = false - pluginViewModel.updatePluginListLocal() + pluginViewModel.updatePluginListLocal( + filterDisabled = url == "disabled", + folderName = if (url.startsWith("folder://")) url.removePrefix("folder://") else null + ) binding.tvtypesChipsScroll.root.isVisible = false } else { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt index 0cbef9cf27a..61098f7a6a0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt @@ -19,6 +19,7 @@ import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager.getPluginPath import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.plugins.SitePlugin +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main @@ -61,6 +62,71 @@ class PluginsViewModel : ViewModel() { var selectedLanguages = listOf() private var currentQuery: String? = null + val selectedPlugins = mutableSetOf() // Set of plugin names/urls + private var isSelectionMode = false + + fun toggleSelectionMode(enabled: Boolean) { + isSelectionMode = enabled + if (!enabled) selectedPlugins.clear() + updateFilteredPlugins() + } + + fun toggleSelection(pluginUrl: String) { + if (selectedPlugins.contains(pluginUrl)) { + selectedPlugins.remove(pluginUrl) + } else { + selectedPlugins.add(pluginUrl) + } + updateFilteredPlugins() + } + + fun batchAction(activity: Activity?, action: BatchAction) = ioSafe { + if (activity == null) return@ioSafe + val pluginsToProcess = plugins.filter { selectedPlugins.contains(it.plugin.second.url) } + + pluginsToProcess.amap { data -> + val (repo, metadata) = data.plugin + val file = if (metadata.url.startsWith("http")) getPluginPath(activity, metadata.internalName, repo) else File(metadata.url) + val exists = file.exists() + + when (action) { + BatchAction.Download -> { + if (!exists) { + PluginManager.downloadPlugin(activity, metadata.url, metadata.fileHash, metadata.internalName, repo, true) + } + } + BatchAction.Delete -> { + if (exists) { + PluginManager.deletePlugin(file) + } + } + BatchAction.Disable -> { + if (exists) { + PluginManager.setPluginDisabled(file.absolutePath, true) + } + } + BatchAction.Enable -> { + if (exists) { + PluginManager.setPluginDisabled(file.absolutePath, false) + PluginManager.loadSinglePluginByPath(activity, file.absolutePath) + } + } + BatchAction.MoveToFolder -> { + // Logic handled in Fragment with folder picker + } + } + } + + toggleSelectionMode(false) + runOnMainThread { + updatePluginListLocal() // Refresh + } + } + + enum class BatchAction { + Download, Delete, Disable, Enable, MoveToFolder + } + companion object { private val repositoryCache: MutableMap> = mutableMapOf() const val TAG = "PLG" @@ -87,6 +153,16 @@ class PluginsViewModel : ViewModel() { ?.also { repositoryCache[repositoryUrl] = it } ?: emptyList() } + private fun isLocalDisabled( + pluginName: String, + repositoryUrl: String, + activity: Activity? = null + ): Boolean { + val path = activity?.let { getPluginPath(it, pluginName, repositoryUrl).absolutePath } + ?: return false + return PluginManager.getDisabledPlugins().contains(path) + } + /** * @param viewModel optional, updates the plugins livedata for that viewModel if included * */ @@ -172,8 +248,16 @@ class PluginsViewModel : ViewModel() { plugin.first ) + val isDisabled = PluginManager.getDisabledPlugins().contains(file.absolutePath) + val (success, message) = if (file.exists()) { - PluginManager.deletePlugin(file) to R.string.plugin_deleted + if (isDisabled) { + PluginManager.setPluginDisabled(file.absolutePath, false) + PluginManager.loadSinglePluginByPath(activity, file.absolutePath) + true to R.string.plugin_loaded + } else { + PluginManager.deletePlugin(file) to R.string.plugin_deleted + } } else { val isEnabled = plugin.second.status != PROVIDER_STATUS_DOWN val message = if (isEnabled) R.string.plugin_loaded else R.string.plugin_downloaded @@ -206,12 +290,25 @@ class PluginsViewModel : ViewModel() { .getStringSet(context.getString(R.string.prefer_media_type_key), emptySet()) ?.contains(TvType.NSFW.ordinal.toString()) == true - val plugins = getPlugins(repositoryUrl) + val plugins = if (repositoryUrl.startsWith("folder://")) { + val folderName = repositoryUrl.removePrefix("folder://") + val pluginNames = DataStoreHelper.getExtensionFolders()[folderName] ?: emptyList() + (PluginManager.getPluginsOnline() + PluginManager.getPluginsLocal()) + .filter { pluginNames.contains(it.internalName) } + .map { "" to it.toSitePlugin() } + } else { + getPlugins(repositoryUrl) + } + val list = plugins.filter { // Show all non-nsfw plugins or all if nsfw is enabled it.second.tvTypes?.contains(TvType.NSFW.name) != true || isAdult }.map { plugin -> - PluginViewData(plugin, isDownloaded(context, plugin.second.internalName, plugin.first)) + PluginViewData( + plugin, + isDownloaded(context, plugin.second.internalName, plugin.first), + isLocalDisabled(plugin.second.internalName, plugin.first, context as? Activity) + ) } this.plugins = list @@ -254,9 +351,18 @@ class PluginsViewModel : ViewModel() { } } + private fun List.applySelection(): List { + return this.map { + it.copy( + isSelected = selectedPlugins.contains(it.plugin.second.url), + isInSelectionMode = isSelectionMode + ) + } + } + fun updateFilteredPlugins() { _filteredPlugins.postValue( - false to plugins.filterTvTypes().filterLang().sortByQuery(currentQuery) + false to plugins.filterTvTypes().filterLang().sortByQuery(currentQuery).applySelection() ) } @@ -283,13 +389,22 @@ class PluginsViewModel : ViewModel() { /** * Update the list but only with the local data. Used for file management. * */ - fun updatePluginListLocal() = viewModelScope.launchSafe { - Log.i(TAG, "updatePluginList = local") + fun updatePluginListLocal(filterDisabled: Boolean = false, folderName: String? = null) = viewModelScope.launchSafe { + Log.i(TAG, "updatePluginList = local, filterDisabled = $filterDisabled, folderName = $folderName") + val disabled = PluginManager.getDisabledPlugins() + val folderPlugins = folderName?.let { DataStoreHelper.getExtensionFolders()[it] } + val downloadedPlugins = (PluginManager.getPluginsOnline() + PluginManager.getPluginsLocal()) .distinctBy { it.filePath } + .filter { !filterDisabled || it.filePath in disabled } + .filter { folderPlugins == null || folderPlugins.contains(it.internalName) } .map { - PluginViewData("" to it.toSitePlugin(), true) + PluginViewData( + "" to it.toSitePlugin(), + true, + it.filePath in disabled + ) } plugins = downloadedPlugins 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 index 4ec005a094d..025f19de1fb 100644 --- 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 @@ -1,7 +1,9 @@ package com.lagradost.cloudstream3.ui.settings.testing import android.view.View +import android.widget.Toast import androidx.fragment.app.activityViewModels +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentTestingBinding import com.lagradost.cloudstream3.mvvm.safe @@ -13,6 +15,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog class TestFragment : BaseFragment( BaseFragment.BindingCreator.Inflate(FragmentTestingBinding::inflate) @@ -88,6 +91,45 @@ class TestFragment : BaseFragment( testViewModel.setFilterMethod(TestViewModel.ProviderFilter.Passed) focusRecyclerView() } + providerTest.setOnDeleteFailedClick { + val failed = testViewModel.getFailedExtensions() + if (failed.isEmpty()) { + showToast(R.string.no_data) + return@setOnDeleteFailedClick + } + + activity?.showMultiDialog( + failed.map { it.first }, + failed.indices.toList(), + getString(R.string.delete_failed), + {} + ) { selectedIndices: List -> + val pathsToDelete = selectedIndices.map { index -> failed[index].second } + testViewModel.deleteExtensions(pathsToDelete) + } + } + + providerTest.setOnRetryFailedClick { + testViewModel.retryFailed() + } + + providerTest.setOnDisableFailedClick { + val failed = testViewModel.getFailedExtensions() + if (failed.isEmpty()) { + showToast(R.string.no_data) + return@setOnDisableFailedClick + } + + activity?.showMultiDialog( + failed.map { it.first }, + failed.indices.toList(), + getString(R.string.disable_failed), + {} + ) { selectedIndices: List -> + val pathsToDisable = selectedIndices.map { index -> failed[index].second } + testViewModel.disableExtensions(pathsToDisable) + } + } } } } \ 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 index c53ff1fcf8a..7ddb130ce91 100644 --- 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 @@ -89,13 +89,20 @@ class TestResultAdapter() : val stackTrace = result.exception?.getStackTracePretty(false)?.ifBlank { null } val messages = result.exception?.getAllMessages()?.ifBlank { null } val resultLog = result.log.joinToString("\n") + + if (result.isLikelyBlocked) { + failDescription.text = itemView.context.getString(R.string.connection_blocked_warning) + failDescription.setTextColor(ContextCompat.getColor(itemView.context, R.color.colorTestWarning)) + } else { + failDescription.text = messages?.lastLine() ?: resultLog.lastLine() + failDescription.setTextColor(ContextCompat.getColor(itemView.context, R.color.grayTextColor)) + } + val fullLog = resultLog + (messages?.let { "\n\nError: $it" } ?: "") + (stackTrace?.let { "\n\n$it" } ?: "") - failDescription.text = messages?.lastLine() ?: resultLog.lastLine() - logButton.setOnClickListener { val builder: AlertDialog.Builder = AlertDialog.Builder(it.context, R.style.AlertDialogCustom) 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 index 65ed47a545a..8c360335516 100644 --- 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 @@ -40,9 +40,13 @@ class TestView @JvmOverloads constructor( var totalProgressBar: ContentLoadingProgressBar? = null var playPauseButton: MaterialButton? = null + var retryFailedButton: MaterialButton? = null + var disableFailedButton: MaterialButton? = null + var deleteFailedButton: MaterialButton? = null var stateListener: (TestState) -> Unit = {} private var state = TestState.None + private var failedCount = 0 init { LayoutInflater.from(context).inflate(R.layout.view_test, this, true) @@ -58,6 +62,9 @@ class TestView @JvmOverloads constructor( totalProgressBar = findViewById(R.id.test_total_progress) playPauseButton = findViewById(R.id.tests_play_pause) + retryFailedButton = findViewById(R.id.tests_retry_failed) + disableFailedButton = findViewById(R.id.tests_disable_failed) + deleteFailedButton = findViewById(R.id.tests_delete_failed) attrs?.let { context.withStyledAttributes(it, R.styleable.TestView) { @@ -84,9 +91,18 @@ class TestView @JvmOverloads constructor( stateListener.invoke(newState) playPauseButton?.setText(newState.stringRes) playPauseButton?.icon = ContextCompat.getDrawable(context, newState.icon) + updateExtraButtonsVisibility() + } + + private fun updateExtraButtonsVisibility() { + val showExtraButtons = failedCount > 0 && state != TestState.Running + retryFailedButton?.isVisible = showExtraButtons + disableFailedButton?.isVisible = showExtraButtons + deleteFailedButton?.isVisible = showExtraButtons } fun setProgress(passed: Int, failed: Int, total: Int?) { + failedCount = failed val totalProgress = passed + failed mainSectionText?.text = "$totalProgress / ${total?.toString() ?: "?"}" testsPassedSectionText?.text = passed.toString() @@ -96,8 +112,11 @@ class TestView @JvmOverloads constructor( totalProgressBar?.animateProgressTo(totalProgress * 1000) totalProgressBar?.isVisible = !(totalProgress == 0 || (total ?: 0) == 0) - if (totalProgress == total) { + + if (totalProgress == total && total != 0) { setState(TestState.Stopped) + } else { + updateExtraButtonsVisibility() } } @@ -116,4 +135,16 @@ class TestView @JvmOverloads constructor( fun setOnFailedClick(listener: OnClickListener) { testsFailedSection?.setOnClickListener(listener) } + + fun setOnDeleteFailedClick(listener: OnClickListener) { + deleteFailedButton?.setOnClickListener(listener) + } + + fun setOnRetryFailedClick(listener: OnClickListener) { + retryFailedButton?.setOnClickListener(listener) + } + + fun setOnDisableFailedClick(listener: OnClickListener) { + disableFailedButton?.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 index 22500d93199..302e73c56a5 100644 --- 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 @@ -3,13 +3,17 @@ package com.lagradost.cloudstream3.ui.settings.testing import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import com.lagradost.cloudstream3.utils.TestingUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.io.File class TestViewModel : ViewModel() { data class TestProgress( @@ -104,4 +108,68 @@ class TestViewModel : ViewModel() { scope?.cancel() scope = null } + + fun deleteExtensions(paths: List) { + viewModelScope.launch { + paths.forEach { path -> + PluginManager.deletePlugin(File(path)) + } + providers.withLock { + providers.removeAll { it.first.sourcePlugin in paths } + passed = providers.count { it.second.success } + failed = providers.count { !it.second.success } + } + total = APIHolder.allProviders.withLock { APIHolder.allProviders.size } + updateProgress() + } + } + + fun disableExtensions(paths: List) { + viewModelScope.launch { + paths.forEach { path -> + PluginManager.setPluginDisabled(path, true) + } + providers.withLock { + providers.removeAll { it.first.sourcePlugin in paths } + passed = providers.count { it.second.success } + failed = providers.count { !it.second.success } + } + total = APIHolder.allProviders.withLock { APIHolder.allProviders.size } + updateProgress() + } + } + + fun getFailedExtensions(): List> { + return providers.withLock { + val failed = providers.filter { !it.second.success }.mapNotNull { + val path = it.first.sourcePlugin ?: return@mapNotNull null + it.first.name to path + }.toSet() + + val passedPaths = providers.filter { it.second.success }.mapNotNull { it.first.sourcePlugin }.toSet() + + failed.filter { it.second !in passedPaths }.distinctBy { it.second } + } + } + + fun retryFailed() { + scope?.cancel() + scope = CoroutineScope(Dispatchers.Default) + + val failedApis = providers.withLock { + val failed = providers.filter { !it.second.success }.map { it.first } + providers.removeAll { !it.second.success } + failed + }.toTypedArray() + + if (failedApis.isEmpty()) return + + failed = providers.count { !it.second.success } + passed = providers.count { it.second.success } + updateProgress() + + TestingUtils.getDeferredProviderTests(scope ?: return, failedApis) { api, result -> + addProvider(api, result) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 19caead21ee..cfad1e5680c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -51,6 +51,9 @@ const val RESULT_SEASON = "result_season" const val RESULT_DUB = "result_dub" const val KEY_RESULT_SORT = "result_sort" const val USER_PINNED_PROVIDERS = "user_pinned_providers" //key for pinned user set +const val SOURCE_PREFERENCE = "source_preference" +const val EXTENSION_FOLDERS = "extension_folders" +const val GLOBAL_PROGRESS = "global_progress" class UserPreferenceDelegate( private val key: String, private val default: T //, private val klass: KClass @@ -527,18 +530,24 @@ object DataStoreHelper { updateTime: Long? = null, ) { if (parentId == null) return + val res = DownloadObjects.ResumeWatching( + parentId, + episodeId, + episode, + season, + updateTime ?: System.currentTimeMillis(), + isFromDownload + ) setKey( "$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString(), - DownloadObjects.ResumeWatching( - parentId, - episodeId, - episode, - season, - updateTime ?: System.currentTimeMillis(), - isFromDownload - ) + res ) + + // Feature 6: Cross-provider sync + (getBookmarkedData(parentId) ?: getSubscribedData(parentId) ?: getFavoritesData(parentId))?.let { meta -> + setGlobalProgress(meta.name, meta.type, res) + } } private fun removeLastWatchedOld(parentId: Int?) { @@ -553,10 +562,17 @@ object DataStoreHelper { fun getLastWatched(id: Int?): DownloadObjects.ResumeWatching? { if (id == null) return null - return getKey( + val local = getKey( "$currentAccount/$RESULT_RESUME_WATCHING", id.toString(), ) + if (local != null) return local + + // Feature 6: Cross-provider sync + (getBookmarkedData(id) ?: getSubscribedData(id) ?: getFavoritesData(id))?.let { meta -> + return getGlobalProgress(meta.name, meta.type) + } + return null } private fun getLastWatchedOld(id: Int?): DownloadObjects.ResumeWatching? { @@ -779,6 +795,32 @@ object DataStoreHelper { } } + fun getSourcePreference(parentId: Int): String? { + return getKey("$currentAccount/$SOURCE_PREFERENCE", parentId.toString()) + } + + fun setSourcePreference(parentId: Int, source: String) { + setKey("$currentAccount/$SOURCE_PREFERENCE", parentId.toString(), source) + } + + fun getExtensionFolders(): Map> { + return getKey("$currentAccount/$EXTENSION_FOLDERS") ?: emptyMap() + } + + fun setExtensionFolders(folders: Map>) { + setKey("$currentAccount/$EXTENSION_FOLDERS", folders) + } + + fun getGlobalProgress(name: String, type: TvType?): DownloadObjects.ResumeWatching? { + val globalId = "${name.lowercase().filter { it.isLetterOrDigit() }}_$type" + return getKey("$currentAccount/$GLOBAL_PROGRESS", globalId) + } + + fun setGlobalProgress(name: String, type: TvType?, progress: DownloadObjects.ResumeWatching) { + val globalId = "${name.lowercase().filter { it.isLetterOrDigit() }}_$type" + setKey("$currentAccount/$GLOBAL_PROGRESS", globalId, progress) + } + var pinnedProviders: Array get() = getKey(USER_PINNED_PROVIDERS) ?: emptyArray() set(value) = setKey(USER_PINNED_PROVIDERS, value) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt index 8c50afee73f..929b414892a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt @@ -59,7 +59,8 @@ object TestingUtils { class TestResultProvider( success: Boolean, val log: List, - val exception: Throwable? + val exception: Throwable?, + val isLikelyBlocked: Boolean = false ) : TestResult(success) @@ -319,7 +320,15 @@ object TestingUtils { TestResultProvider(false, logger.getRawLog(), null) } } catch (e: Throwable) { - TestResultProvider(false, logger.getRawLog(), e) + val isLikelyBlocked = when (e) { + is java.net.SocketTimeoutException -> true + is java.net.UnknownHostException -> true + is java.net.ConnectException -> true + is javax.net.ssl.SSLHandshakeException -> true + else -> e.message?.contains("timeout", ignoreCase = true) == true || + e.message?.contains("connection", ignoreCase = true) == true + } + TestResultProvider(false, logger.getRawLog(), e, isLikelyBlocked) } callback.invoke(api, result) } diff --git a/app/src/main/res/drawable/ic_baseline_check_circle_24.xml b/app/src/main/res/drawable/ic_baseline_check_circle_24.xml new file mode 100644 index 00000000000..305cc461e35 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_check_circle_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_radio_button_unchecked_24.xml b/app/src/main/res/drawable/ic_baseline_radio_button_unchecked_24.xml new file mode 100644 index 00000000000..a9a243dbc19 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_radio_button_unchecked_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_extensions.xml b/app/src/main/res/layout/fragment_extensions.xml index b7cf4b6cd34..79e7142f217 100644 --- a/app/src/main/res/layout/fragment_extensions.xml +++ b/app/src/main/res/layout/fragment_extensions.xml @@ -129,56 +129,81 @@ android:layout_height="wrap_content" android:orientation="horizontal"> - - - - - - - + + + + + + + - - - - + + + + + + + + android:orientation="horizontal"> + + + + + + @@ -26,6 +27,19 @@ app:titleTextColor="?attr/textColor" tools:title="Overlord" /> + + - + + + + + + + + + + + + + + + + + + + + + - diff --git a/app/src/main/res/menu/extensions_menu.xml b/app/src/main/res/menu/extensions_menu.xml new file mode 100644 index 00000000000..4f1b183ee57 --- /dev/null +++ b/app/src/main/res/menu/extensions_menu.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/plugin_selection.xml b/app/src/main/res/menu/plugin_selection.xml new file mode 100644 index 00000000000..47e285f2fa0 --- /dev/null +++ b/app/src/main/res/menu/plugin_selection.xml @@ -0,0 +1,29 @@ + + + + + + + + \ 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 31cf951cf5f..86df425130a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -231,6 +231,8 @@ E No Episodes found Delete + Delete + Disable Delete File Delete Files Delete (%1$d | %2$s) @@ -519,6 +521,7 @@ Download the list of sites you want to use Downloaded: %d Disabled: %d + Disabled extensions Not downloaded: %d Updated %d plugins CloudStream has no sites installed by default. You need to install the sites from repositories. @@ -721,6 +724,12 @@ Volume has exceeded 100% Slide up again to go beyond 100% Update Plugins + Sync all repositories + Successfully synced all repositories + Add folder + Folder name + Likely ISP block detected. Try DNS-over-HTTPS or a VPN. + Retry Update plugins manually Starting plugin update process! diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000000..6c1139ec06a --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 From e53b7df9dde6e8efc2b15d051f9d7c0f8c159033 Mon Sep 17 00:00:00 2001 From: amol Date: Mon, 6 Jul 2026 06:21:23 +0000 Subject: [PATCH 2/5] Propagate CancellationException in core safe/launchSafe helpers The central error-handling utilities (safe, safeAsync, launchSafe, safeApiCall, and the deprecated normalSafeApiCall/suspendSafeApiCall) caught Throwable and turned any failure into null / a Failure resource. This also swallowed CancellationException, breaking coroutine structured concurrency: cancelling a job would be silently absorbed (and even logged as an error) instead of propagating. Rethrow CancellationException before the generic catch so cancellation keeps propagating while genuine errors are still logged. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../cloudstream3/mvvm/ArchComponentExt.kt | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt index e13bcf5ec65..f282757db22 100644 --- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt +++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt @@ -84,6 +84,8 @@ fun logError(throwable: Throwable) { fun normalSafeApiCall(apiCall: () -> T): T? { return try { apiCall.invoke() + } catch (cancellation: CancellationException) { + throw cancellation } catch (throwable: Throwable) { logError(throwable) return null @@ -91,10 +93,15 @@ fun normalSafeApiCall(apiCall: () -> T): T? { } /** Catches any exception (or error) and only logs it. - * Will return null on exceptions. */ + * Will return null on exceptions. + * + * [CancellationException] is rethrown so that coroutine cancellation keeps + * propagating instead of being silently swallowed. */ fun safe(apiCall: () -> T): T? { return try { apiCall.invoke() + } catch (cancellation: CancellationException) { + throw cancellation } catch (throwable: Throwable) { logError(throwable) return null @@ -102,10 +109,15 @@ fun safe(apiCall: () -> T): T? { } /** Catches any exception (or error) and only logs it. - * Will return null on exceptions. */ + * Will return null on exceptions. + * + * [CancellationException] is rethrown so that coroutine cancellation keeps + * propagating instead of being silently swallowed. */ suspend fun safeAsync(apiCall: suspend () -> T): T? { return try { apiCall.invoke() + } catch (cancellation: CancellationException) { + throw cancellation } catch (throwable: Throwable) { logError(throwable) return null @@ -120,6 +132,8 @@ suspend fun safeAsync(apiCall: suspend () -> T): T? { suspend fun suspendSafeApiCall(apiCall: suspend () -> T): T? { return try { apiCall.invoke() + } catch (cancellation: CancellationException) { + throw cancellation } catch (throwable: Throwable) { logError(throwable) return null @@ -152,6 +166,8 @@ fun CoroutineScope.launchSafe( val obj: suspend CoroutineScope.() -> Unit = { try { block() + } catch (cancellation: CancellationException) { + throw cancellation } catch (throwable: Throwable) { logError(throwable) } @@ -238,6 +254,8 @@ suspend fun safeApiCall( return withContext(Dispatchers.IO) { try { Resource.Success(apiCall.invoke()) + } catch (cancellation: CancellationException) { + throw cancellation } catch (throwable: Throwable) { logError(throwable) throwAbleToResource(throwable) From d0f7055eae2a7b964fa6c2dddb65953ed5026ebc Mon Sep 17 00:00:00 2001 From: amol Date: Mon, 6 Jul 2026 06:23:25 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20security=20hardening=20=E2=80=94=20r?= =?UTF-8?q?emove=20hardcoded=20API=20key,=20fix=20SSL=20validation,=20hard?= =?UTF-8?q?en=20WebView=20and=20Runtime.exec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move hardcoded TMDB API key from SearchSuggestionApi to BuildConfig (sourced from env var TMDB_API_KEY / local.properties tmdb.key) - Change CS3IPlayer.ignoreSSL default from true to false so SSL certificate validation is enforced unless explicitly opted out - SSLTrustManager: reject null/empty certificate chains, log when accepting unverified certificates - WebViewResolver: cancel on SSL errors instead of blindly proceeding - WebviewFragment RepoApi: block non-HTTPS repository URLs from the JavaScript interface - Use Runtime.exec(String[]) array form instead of string form to prevent potential command injection in getprop and logcat calls Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/build.gradle.kts | 5 +++++ .../cloudstream3/ui/WebviewFragment.kt | 5 +++++ .../cloudstream3/ui/player/CS3IPlayer.kt | 2 +- .../cloudstream3/ui/player/SSLTrustManager.kt | 20 ++++++++++++++++--- .../ui/search/SearchSuggestionApi.kt | 3 ++- .../ui/settings/SettingsUpdates.kt | 4 ++-- .../cloudstream3/utils/InAppUpdater.kt | 2 +- .../network/WebViewResolver.android.kt | 3 ++- 8 files changed, 35 insertions(+), 9 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6c784f3ef8d..e30dae085f0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -117,6 +117,11 @@ android { "BUILD_DATE", "${System.currentTimeMillis()}" ) + buildConfigField( + "String", + "TMDB_API_KEY", + "\"" + (System.getenv("TMDB_API_KEY") ?: localProperties["tmdb.key"] ?: "") + "\"" + ) buildConfigField( "String", "SIMKL_CLIENT_ID", diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt index 0d951bf6a7a..5af9b8b80a4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.ui import android.os.Bundle +import android.util.Log import android.view.View import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest @@ -64,6 +65,10 @@ class WebviewFragment : BaseFragment( private class RepoApi(val activity: FragmentActivity?) { @JavascriptInterface fun installRepo(repoUrl: String) { + if (!repoUrl.startsWith("https://")) { + Log.w("RepoApi", "Blocked non-HTTPS repo URL: $repoUrl") + return + } activity?.loadRepository(repoUrl) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index d7e10c81441..b2f2f27ac33 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -158,7 +158,7 @@ class CS3IPlayer : IPlayer { it.isCommandAvailable(Player.COMMAND_GET_CURRENT_MEDIA_ITEM) && it.isCurrentMediaItemSeekable } ?: false - private var ignoreSSL: Boolean = true + private var ignoreSSL: Boolean = false private var playBackSpeed: Float = 1.0f private var lastMuteVolume: Float = 1.0f diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/SSLTrustManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/SSLTrustManager.kt index 0c7226d29c7..b85d8b95955 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/SSLTrustManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/SSLTrustManager.kt @@ -1,16 +1,30 @@ package com.lagradost.cloudstream3.ui.player +import android.util.Log +import java.security.cert.CertificateException import java.security.cert.X509Certificate import javax.net.ssl.X509TrustManager +/** + * Permissive trust manager that skips certificate validation. + * Only used when the user explicitly opts in to ignoring SSL errors + * for media playback from sources with self-signed or invalid certificates. + */ class SSLTrustManager : X509TrustManager { - override fun checkClientTrusted(p0: Array?, p1: String?) { + override fun checkClientTrusted(chain: Array?, authType: String?) { + if (chain.isNullOrEmpty()) { + throw CertificateException("No client certificate chain provided") + } } - override fun checkServerTrusted(p0: Array?, p1: String?) { + override fun checkServerTrusted(chain: Array?, authType: String?) { + if (chain.isNullOrEmpty()) { + throw CertificateException("No server certificate chain provided") + } + Log.w("SSLTrustManager", "Accepting unverified server certificate for: ${chain[0].subjectDN}") } override fun getAcceptedIssuers(): Array { return arrayOf() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt index 8dbd7817898..2684e835a70 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.ui.search import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.nicehttp.NiceResponse @@ -11,7 +12,7 @@ import com.lagradost.nicehttp.NiceResponse */ object SearchSuggestionApi { private const val TMDB_API_URL = "https://api.themoviedb.org/3/search/multi" - private const val TMDB_API_KEY = "e6333b32409e02a4a6eba6fb7ff866bb" + private val TMDB_API_KEY: String = BuildConfig.TMDB_API_KEY data class TmdbSearchResult( @JsonProperty("results") val results: List? diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index c04215594e1..374fb20a9fe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -156,7 +156,7 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { val logList = mutableListOf() try { // https://developer.android.com/studio/command-line/logcat - val process = Runtime.getRuntime().exec("logcat -d") + val process = Runtime.getRuntime().exec(arrayOf("logcat", "-d")) val bufferedReader = BufferedReader(InputStreamReader(process.inputStream)) bufferedReader.lineSequence().forEach { logList.add(it) } } catch (e: Exception) { @@ -173,7 +173,7 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { } binding.clearBtt.setOnClickListener { - Runtime.getRuntime().exec("logcat -c") + Runtime.getRuntime().exec(arrayOf("logcat", "-c")) dialog.dismissSafe(activity) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index b01f6e07e27..923450db1fd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -355,7 +355,7 @@ object InAppUpdater { private fun isMiUi(): Boolean = !getSystemProperty("ro.miui.ui.version.name").isNullOrEmpty() private fun getSystemProperty(propName: String): String? = try { - val p = Runtime.getRuntime().exec("getprop $propName") + val p = Runtime.getRuntime().exec(arrayOf("getprop", propName)) BufferedReader(InputStreamReader(p.inputStream), 1024).use { it.readLine() } diff --git a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt index bc443b3f878..0f2b6c590e8 100644 --- a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt +++ b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/network/WebViewResolver.android.kt @@ -250,7 +250,8 @@ actual class WebViewResolver actual constructor( handler: SslErrorHandler?, error: SslError? ) { - handler?.proceed() // Ignore ssl issues + Log.e(TAG, "SSL error in WebView: ${error?.primaryError}, url: ${error?.url}") + handler?.cancel() } } webView?.loadUrl(url, headers.toMap()) From 91fc25baaf810bce6be528280ba6b7ea46e0504b Mon Sep 17 00:00:00 2001 From: amol Date: Mon, 6 Jul 2026 06:25:46 +0000 Subject: [PATCH 4/5] Refactor duplicated result UI code into shared helpers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../cloudstream3/ui/result/EpisodeAdapter.kt | 315 ++++++++---------- .../cloudstream3/ui/result/ResultFragment.kt | 59 ++++ .../ui/result/ResultFragmentPhone.kt | 37 +- .../ui/result/ResultFragmentTv.kt | 36 +- 4 files changed, 198 insertions(+), 249 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt index 5e550416477..bb0e36a5ce6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt @@ -2,7 +2,10 @@ package com.lagradost.cloudstream3.ui.result import android.content.Context import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.setPadding @@ -18,9 +21,11 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.seconds import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState +import com.google.android.material.progressindicator.CircularProgressIndicator import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DownloadClickEvent +import com.lagradost.cloudstream3.ui.download.button.PieFetchButton import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE @@ -156,83 +161,14 @@ class EpisodeAdapter( episodeHolder.setPadding(0) } - downloadButton.isVisible = hasDownloadSupport - downloadButton.setDefaultClickListener( - DownloadObjects.DownloadEpisodeCached( - name = item.name, - poster = item.poster, - episode = item.episode, - season = item.season, - id = item.id, - parentId = item.parentId, - score = item.score, - description = item.description, - cacheTime = System.currentTimeMillis(), - ), null - ) { - when (it.action) { - DOWNLOAD_ACTION_DOWNLOAD -> { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_DOWNLOAD_EPISODE, - item - ) - ) - } - - DOWNLOAD_ACTION_LONG_CLICK -> { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_DOWNLOAD_MIRROR, - item - ) - ) - } - - else -> { - downloadClickCallback.invoke(it) - } - } - } - - val status = VideoDownloadManager.downloadStatus[item.id] - downloadButton.resetView() - downloadButton.setPersistentId(item.id) - downloadButton.setStatus(status) - - val name = - if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" - episodeFiller.isVisible = item.isFiller == true - episodeText.text = - name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name - episodeText.isSelected = true // is needed for text repeating - - if (item.videoWatchState == VideoWatchState.Watched) { - // This cannot be done in getDisplayPosition() as when you have not watched something - // the duration and position is 0 - //episodeProgress.max = 1 - //episodeProgress.progress = 1 - episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) - episodeProgress.isVisible = false - } else { - val displayPos = item.getDisplayPosition() - val durationSec = (item.duration / 1000).toInt() - val progressSec = (displayPos / 1000).toInt() - - if (displayPos >= item.duration && displayPos > 0) { - episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) - episodeProgress.isVisible = false - } else { - episodePlayIcon.setImageResource(R.drawable.netflix_play) - episodeProgress.apply { - max = durationSec - progress = progressSec - isVisible = displayPos > 0L - } - } - } + bindDownloadButton(downloadButton, item, position) + bindEpisodeText(episodeText, episodeFiller, item) + bindWatchProgress( + episodePlayIcon, + episodeProgress, + item, + R.drawable.netflix_play + ) val posterVisible = !item.poster.isNullOrBlank() if (posterVisible) { @@ -354,20 +290,7 @@ class EpisodeAdapter( } } - itemView.setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(position, ACTION_CLICK_DEFAULT, item)) - } - - if (isLayout(TV)) { - itemView.isFocusable = true - itemView.isFocusableInTouchMode = true - //itemView.touchscreenBlocksFocus = false - } - - itemView.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) - return@setOnLongClickListener true - } + bindItemViewInteractions(itemView, item, position) } is ResultEpisodeBinding -> { @@ -377,105 +300,137 @@ class EpisodeAdapter( } binding.apply { - downloadButton.isVisible = hasDownloadSupport - downloadButton.setDefaultClickListener( - DownloadObjects.DownloadEpisodeCached( - name = item.name, - poster = item.poster, - episode = item.episode, - season = item.season, - id = item.id, - parentId = item.parentId, - score = item.score, - description = item.description, - cacheTime = System.currentTimeMillis(), - ), null - ) { - when (it.action) { - DOWNLOAD_ACTION_DOWNLOAD -> { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_DOWNLOAD_EPISODE, - item - ) - ) - } - - DOWNLOAD_ACTION_LONG_CLICK -> { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_DOWNLOAD_MIRROR, - item - ) - ) - } + bindDownloadButton(downloadButton, item, position) + bindEpisodeText(episodeText, episodeFiller, item) + bindWatchProgress( + episodePlayIcon, + episodeProgress, + item, + R.drawable.play_button_transparent + ) - else -> { - downloadClickCallback.invoke(it) - } - } - } + bindItemViewInteractions(itemView, item, position) - val status = VideoDownloadManager.downloadStatus[item.id] - downloadButton.resetView() - downloadButton.setPersistentId(item.id) - downloadButton.setStatus(status) - - val name = - if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" - episodeFiller.isVisible = item.isFiller == true - episodeText.text = - name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name - episodeText.isSelected = true // is needed for text repeating - - if (item.videoWatchState == VideoWatchState.Watched) { - episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) - episodeProgress.isVisible = false - } else { - val displayPos = item.getDisplayPosition() - val durationSec = (item.duration / 1000).toInt() - val progressSec = (displayPos / 1000).toInt() + //binding.resultEpisodeDownload.isVisible = hasDownloadSupport + //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport + } + } + } + } - if (displayPos >= item.duration && displayPos > 0) { - episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) - episodeProgress.isVisible = false - } else { - episodePlayIcon.setImageResource(R.drawable.play_button_transparent) - episodeProgress.apply { - max = durationSec - progress = progressSec - isVisible = displayPos > 0L - } - } - } + private fun bindDownloadButton( + downloadButton: PieFetchButton, + item: ResultEpisode, + position: Int + ) { + downloadButton.isVisible = hasDownloadSupport + downloadButton.setDefaultClickListener( + DownloadObjects.DownloadEpisodeCached( + name = item.name, + poster = item.poster, + episode = item.episode, + season = item.season, + id = item.id, + parentId = item.parentId, + score = item.score, + description = item.description, + cacheTime = System.currentTimeMillis(), + ), null + ) { + when (it.action) { + DOWNLOAD_ACTION_DOWNLOAD -> { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_DOWNLOAD_EPISODE, + item + ) + ) + } - itemView.setOnClickListener { - clickCallback.invoke( - EpisodeClickEvent( - position, - ACTION_CLICK_DEFAULT, - item - ) + DOWNLOAD_ACTION_LONG_CLICK -> { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_DOWNLOAD_MIRROR, + item ) - } + ) + } - if (isLayout(TV)) { - itemView.isFocusable = true - itemView.isFocusableInTouchMode = true - //itemView.touchscreenBlocksFocus = false - } + else -> { + downloadClickCallback.invoke(it) + } + } + } - itemView.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) - return@setOnLongClickListener true - } + val status = VideoDownloadManager.downloadStatus[item.id] + downloadButton.resetView() + downloadButton.setPersistentId(item.id) + downloadButton.setStatus(status) + } - //binding.resultEpisodeDownload.isVisible = hasDownloadSupport - //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport + private fun bindEpisodeText( + episodeText: TextView, + episodeFiller: View, + item: ResultEpisode + ) { + val name = + if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" + episodeFiller.isVisible = item.isFiller == true + episodeText.text = + name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name + episodeText.isSelected = true // is needed for text repeating + } + + private fun bindWatchProgress( + episodePlayIcon: ImageView, + episodeProgress: CircularProgressIndicator, + item: ResultEpisode, + playIconRes: Int + ) { + if (item.videoWatchState == VideoWatchState.Watched) { + // This cannot be done in getDisplayPosition() as when you have not watched something + // the duration and position is 0 + episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) + episodeProgress.isVisible = false + } else { + val displayPos = item.getDisplayPosition() + val durationSec = (item.duration / 1000).toInt() + val progressSec = (displayPos / 1000).toInt() + + if (displayPos >= item.duration && displayPos > 0) { + episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) + episodeProgress.isVisible = false + } else { + episodePlayIcon.setImageResource(playIconRes) + episodeProgress.apply { + max = durationSec + progress = progressSec + isVisible = displayPos > 0L } } } } + + private fun bindItemViewInteractions( + itemView: View, + item: ResultEpisode, + position: Int + ) { + itemView.setOnClickListener { + clickCallback.invoke(EpisodeClickEvent(position, ACTION_CLICK_DEFAULT, item)) + } + + if (isLayout(TV)) { + itemView.isFocusable = true + itemView.isFocusableInTouchMode = true + //itemView.touchscreenBlocksFocus = false + } + + itemView.setOnLongClickListener { + clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) + return@setOnLongClickListener true + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt index cbf94fd9796..17558174d3c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt @@ -1,12 +1,17 @@ package com.lagradost.cloudstream3.ui.result +import android.annotation.SuppressLint +import android.app.Dialog import android.os.Bundle import android.widget.ImageView import android.widget.TextView +import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import coil3.dispose +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score @@ -20,6 +25,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UiImage const val START_ACTION_RESUME_LATEST = 1 @@ -314,6 +320,59 @@ object ResultFragment { return StoredData(url, apiName, name, showFillers, dubStatus, start, playerAction, restart) } + /** + * Shows, updates or dismisses the bottom loading dialog that displays link + * loading progress. Shared between the phone and TV result fragments. + * + * @param load the current link loading progress, or null to dismiss the dialog. + * @param currentDialog the fragment's currently shown loading dialog, if any. + * @param setLoadingDialog callback used to persist the new dialog reference + * back on the fragment (also invoked with null when the dialog is dismissed). + */ + @SuppressLint("SetTextI18n") + fun Fragment.updateLoadedLinksDialog( + load: LinkProgress?, + currentDialog: Dialog?, + viewModel: ResultViewModel2, + setLoadingDialog: (Dialog?) -> Unit, + ) { + if (load == null) { + currentDialog?.dismissSafe(activity) + setLoadingDialog(null) + return + } + + var dialog = currentDialog + if (dialog?.isShowing != true) { + dialog?.dismissSafe(activity) + dialog = null + } + dialog = dialog ?: context?.let { ctx -> + val builder = BottomSheetDialog(ctx) + builder.setContentView(R.layout.bottom_loading) + builder.setOnDismissListener { + setLoadingDialog(null) + viewModel.cancelLinks() + } + builder.setCanceledOnTouchOutside(true) + builder.show() + builder + } + setLoadingDialog(dialog) + + dialog?.findViewById(R.id.overlay_loading_skip_button)?.apply { + if (load.linksLoaded <= 0) { + isInvisible = true + } else { + setOnClickListener { + viewModel.skipLoading() + } + isVisible = true + text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})" + } + } + } + /*private fun reloadViewModel(forceReload: Boolean) { if (!viewModel.hasLoaded() || forceReload) { val storedData = getStoredData(activity ?: context ?: return) ?: return diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index 38b24b26517..925a00f94f2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -19,7 +19,6 @@ import android.widget.ArrayAdapter import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone -import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView import androidx.core.widget.doOnTextChanged @@ -30,8 +29,6 @@ import com.discord.panels.PanelState import com.discord.panels.PanelsChildGestureRegionObserver import com.google.android.gms.cast.framework.CastButtonFactory import com.google.android.gms.cast.framework.CastContext -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.DubStatus @@ -67,6 +64,7 @@ import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData +import com.lagradost.cloudstream3.ui.result.ResultFragment.updateLoadedLinksDialog import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper @@ -1292,38 +1290,7 @@ open class ResultFragmentPhone : BaseFragment( observeNullable(viewModel.loadedLinks) { load -> - if (load == null) { - loadingDialog?.dismissSafe(activity) - loadingDialog = null - return@observeNullable - } - if (loadingDialog?.isShowing != true) { - loadingDialog?.dismissSafe(activity) - loadingDialog = null - } - loadingDialog = loadingDialog ?: context?.let { ctx -> - val builder = BottomSheetDialog(ctx) - builder.setContentView(R.layout.bottom_loading) - builder.setOnDismissListener { - loadingDialog = null - viewModel.cancelLinks() - } - builder.setCanceledOnTouchOutside(true) - builder.show() - builder - } - loadingDialog?.findViewById(R.id.overlay_loading_skip_button)?.apply { - if (load.linksLoaded <= 0) { - isInvisible = true - } else { - setOnClickListener { - viewModel.skipLoading() - } - isVisible = true - @SuppressLint("SetTextI18n") - text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})" - } - } + updateLoadedLinksDialog(load, loadingDialog, viewModel) { loadingDialog = it } } observeNullable(viewModel.selectedSeason) { text -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt index cfbacc5d13f..a432c449018 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt @@ -11,13 +11,10 @@ import android.view.animation.DecelerateInterpolator import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone -import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.LoadResponse @@ -38,6 +35,7 @@ import com.lagradost.cloudstream3.ui.player.NEXT_WATCH_EPISODE_PERCENTAGE import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData +import com.lagradost.cloudstream3.ui.result.ResultFragment.updateLoadedLinksDialog import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED import com.lagradost.cloudstream3.ui.search.SearchAdapter @@ -726,37 +724,7 @@ class ResultFragmentTv : BaseFragment( } observeNullable(viewModel.loadedLinks) { load -> - if (load == null) { - loadingDialog?.dismissSafe(activity) - loadingDialog = null - return@observeNullable - } - if (loadingDialog?.isShowing != true) { - loadingDialog?.dismissSafe(activity) - loadingDialog = null - } - loadingDialog = loadingDialog ?: context?.let { ctx -> - val builder = BottomSheetDialog(ctx) - builder.setContentView(R.layout.bottom_loading) - builder.setOnDismissListener { - loadingDialog = null - viewModel.cancelLinks() - } - builder.setCanceledOnTouchOutside(true) - builder.show() - builder - } - loadingDialog?.findViewById(R.id.overlay_loading_skip_button)?.apply { - if (load.linksLoaded <= 0) { - isInvisible = true - } else { - setOnClickListener { - viewModel.skipLoading() - } - isVisible = true - text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})" - } - } + updateLoadedLinksDialog(load, loadingDialog, viewModel) { loadingDialog = it } } From 004560945716beb30e21edec1b6c7294ec3c22f8 Mon Sep 17 00:00:00 2001 From: amol Date: Mon, 6 Jul 2026 06:26:01 +0000 Subject: [PATCH 5/5] test: add unit tests for library utils (Levenshtein, SubtitleHelper, JsUnpacker, StringUtils) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../cloudstream3/utils/JsUnpackerTest.kt | 39 ++++++++ .../cloudstream3/utils/LevenshteinTest.kt | 81 +++++++++++++++ .../cloudstream3/utils/StringUtilsTest.kt | 42 ++++++++ .../cloudstream3/utils/SubtitleHelperTest.kt | 98 +++++++++++++++++++ 4 files changed, 260 insertions(+) create mode 100644 library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsUnpackerTest.kt create mode 100644 library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/LevenshteinTest.kt create mode 100644 library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/StringUtilsTest.kt create mode 100644 library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelperTest.kt diff --git a/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsUnpackerTest.kt b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsUnpackerTest.kt new file mode 100644 index 00000000000..0e3d00415ed --- /dev/null +++ b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsUnpackerTest.kt @@ -0,0 +1,39 @@ +package com.lagradost.cloudstream3.utils + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class JsUnpackerTest { + + // Minimal P.A.C.K.E.R. coded payload: symbols "hello" and "world" indexed in base 2. + private val packed = + "eval(function(p,a,c,k,e,d){}('0 1',2,2,'hello|world'.split('|'),0,{}))" + + @Test + fun detectsPackedJs() { + assertTrue(JsUnpacker(packed).detect()) + } + + @Test + fun doesNotDetectPlainJs() { + assertFalse(JsUnpacker("var x = 1; console.log(x);").detect()) + } + + @Test + fun unpacksPackedJs() { + assertEquals("hello world", JsUnpacker(packed).unpack()) + } + + @Test + fun unpackReturnsNullForNullInput() { + assertNull(JsUnpacker(null).unpack()) + } + + @Test + fun unpackReturnsNullForNonPackedInput() { + assertNull(JsUnpacker("just some text without packer structure").unpack()) + } +} diff --git a/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/LevenshteinTest.kt b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/LevenshteinTest.kt new file mode 100644 index 00000000000..ace060b48f6 --- /dev/null +++ b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/LevenshteinTest.kt @@ -0,0 +1,81 @@ +package com.lagradost.cloudstream3.utils + +import com.lagradost.cloudstream3.Prerelease +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(Prerelease::class) +class LevenshteinTest { + + @Test + fun ratioOfIdenticalStringsIsHundred() { + assertEquals(100, Levenshtein.ratio("kitten", "kitten")) + } + + @Test + fun ratioOfTwoEmptyStringsIsHundred() { + assertEquals(100, Levenshtein.ratio("", "")) + } + + @Test + fun ratioWithOneEmptyStringIsZero() { + assertEquals(0, Levenshtein.ratio("abc", "")) + assertEquals(0, Levenshtein.ratio("", "abc")) + } + + @Test + fun ratioOfStringsWithNoCommonCharactersIsZero() { + // No characters in common -> edit distance equals the combined length. + assertEquals(0, Levenshtein.ratio("abc", "xyz")) + } + + @Test + fun ratioIsSymmetric() { + assertEquals( + Levenshtein.ratio("kitten", "sitting"), + Levenshtein.ratio("sitting", "kitten") + ) + } + + @Test + fun ratioIsBoundedBetweenZeroAndHundred() { + val score = Levenshtein.ratio("similar text", "similor test") + assertTrue(score in 0..100) + } + + @Test + fun ratioAppliesProcessor() { + // Without the processor these differ only by case. + assertEquals(100, Levenshtein.ratio("ABC", "abc") { it.lowercase() }) + } + + @Test + fun partialRatioOfIdenticalStringsIsHundred() { + assertEquals(100, Levenshtein.partialRatio("hello", "hello")) + } + + @Test + fun partialRatioFindsFullContainment() { + // The shorter string is fully contained in the longer one. + assertEquals(100, Levenshtein.partialRatio("bc", "abcd")) + } + + @Test + fun partialRatioOfTwoEmptyStringsIsHundred() { + assertEquals(100, Levenshtein.partialRatio("", "")) + } + + @Test + fun partialRatioWithOneEmptyStringIsZero() { + assertEquals(0, Levenshtein.partialRatio("abc", "")) + assertEquals(0, Levenshtein.partialRatio("", "abc")) + } + + @Test + fun partialRatioIsAtLeastRatioForContainedSubstring() { + val ratio = Levenshtein.ratio("bc", "abcd") + val partial = Levenshtein.partialRatio("bc", "abcd") + assertTrue(partial >= ratio) + } +} diff --git a/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/StringUtilsTest.kt b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/StringUtilsTest.kt new file mode 100644 index 00000000000..34a5c1164fe --- /dev/null +++ b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/StringUtilsTest.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream3.utils + +import com.lagradost.cloudstream3.utils.StringUtils.decodeUri +import com.lagradost.cloudstream3.utils.StringUtils.encodeUri +import kotlin.test.Test +import kotlin.test.assertEquals + +class StringUtilsTest { + + @Test + fun encodesSpacesAsPlus() { + assertEquals("a+b", "a b".encodeUri()) + } + + @Test + fun encodesReservedCharacters() { + assertEquals("a%2Fb", "a/b".encodeUri()) + assertEquals("a%26b%3Dc", "a&b=c".encodeUri()) + } + + @Test + fun decodesPlusAsSpace() { + assertEquals("a b", "a+b".decodeUri()) + } + + @Test + fun decodesPercentEscapes() { + assertEquals("a/b", "a%2Fb".decodeUri()) + } + + @Test + fun encodeThenDecodeRoundTrips() { + val original = "hello world & special = ?/#" + assertEquals(original, original.encodeUri().decodeUri()) + } + + @Test + fun encodeThenDecodePreservesUnicode() { + val original = "café Ünïcödé 日本語" + assertEquals(original, original.encodeUri().decodeUri()) + } +} diff --git a/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelperTest.kt b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelperTest.kt new file mode 100644 index 00000000000..856b32f2c11 --- /dev/null +++ b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/SubtitleHelperTest.kt @@ -0,0 +1,98 @@ +package com.lagradost.cloudstream3.utils + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull + +@Suppress("DEPRECATION") +class SubtitleHelperTest { + + @Test + fun codeToEnglishNameResolvesIso6391() { + assertEquals("English", SubtitleHelper.fromTagToEnglishLanguageName("en")) + assertEquals("French", SubtitleHelper.fromTagToEnglishLanguageName("fr")) + assertEquals("German", SubtitleHelper.fromTagToEnglishLanguageName("de")) + } + + @Test + fun codeToEnglishNameIsCaseInsensitive() { + assertEquals("English", SubtitleHelper.fromTagToEnglishLanguageName("EN")) + } + + @Test + fun codeToEnglishNameReturnsNullForUnknownOrBlank() { + assertNull(SubtitleHelper.fromTagToEnglishLanguageName("zzz")) + assertNull(SubtitleHelper.fromTagToEnglishLanguageName("")) + assertNull(SubtitleHelper.fromTagToEnglishLanguageName(null)) + // A single char is below the minimum length and must not match. + assertNull(SubtitleHelper.fromTagToEnglishLanguageName("e")) + } + + @Test + fun languageNameToIetfTag() { + assertEquals("en", SubtitleHelper.fromLanguageToTagIETF("English")) + assertEquals("es", SubtitleHelper.fromLanguageToTagIETF("Spanish")) + } + + @Test + fun nativeNameToIetfTag() { + assertEquals("es", SubtitleHelper.fromLanguageToTagIETF("Español")) + assertEquals("de", SubtitleHelper.fromLanguageToTagIETF("Deutsch")) + } + + @Test + fun languageNameToIetfTagStripsGarbageSuffix() { + assertEquals("en", SubtitleHelper.fromLanguageToTagIETF("English (original audio)")) + assertEquals("en", SubtitleHelper.fromLanguageToTagIETF("English 123")) + } + + @Test + fun languageNameToIetfTagHalfMatch() { + // Exact matching cannot resolve this, but a half match should. + assertNull(SubtitleHelper.fromLanguageToTagIETF("Englishhh", halfMatch = false)) + assertEquals("en", SubtitleHelper.fromLanguageToTagIETF("Englishhh", halfMatch = true)) + } + + @Test + fun deprecatedTwoLetterHelpersResolveNames() { + assertEquals("English", SubtitleHelper.fromTwoLettersToLanguage("en")) + assertEquals("en", SubtitleHelper.fromLanguageToTwoLetters("English", false)) + } + + @Test + fun openSubtitlesTagLookup() { + assertEquals("af", SubtitleHelper.fromCodeToOpenSubtitlesTag("af")) + } + + @Test + fun isWellFormedTagRejectsBlankOrTooShortInput() { + assertFalse(SubtitleHelper.isWellFormedTagIETF(null)) + assertFalse(SubtitleHelper.isWellFormedTagIETF("")) + assertFalse(SubtitleHelper.isWellFormedTagIETF("e")) + } + + @Test + fun getFlagFromIsoCountryCode() { + // Regional indicator symbols for "US". + assertEquals("\uD83C\uDDFA\uD83C\uDDF8", SubtitleHelper.getFlagFromIso("us")) + } + + @Test + fun getFlagFromIsoLanguageTagWithCountry() { + assertEquals("\uD83C\uDDFA\uD83C\uDDF8", SubtitleHelper.getFlagFromIso("en-US")) + } + + @Test + fun getFlagFromIsoSpecialCasedQt() { + // "qt" is special-cased to a gorilla emoji. + assertEquals("\uD83E\uDD8D", SubtitleHelper.getFlagFromIso("qt")) + } + + @Test + fun getFlagFromIsoReturnsNullForBlankOrTooShort() { + assertNull(SubtitleHelper.getFlagFromIso(null)) + assertNull(SubtitleHelper.getFlagFromIso("")) + assertNull(SubtitleHelper.getFlagFromIso("x")) + } +}