From 69443b996f1fd94488f3d434375b3238d3e616d4 Mon Sep 17 00:00:00 2001 From: CloudStream User Date: Wed, 3 Jun 2026 14:31:51 +0530 Subject: [PATCH 1/7] 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 78d567df262dd89cc29c0ae091c3cac27f6eaef8 Mon Sep 17 00:00:00 2001 From: CloudStream User Date: Thu, 4 Jun 2026 10:57:22 +0530 Subject: [PATCH 2/7] Fix lint issues for CodeFactor --- .../cloudstream3/plugins/PluginManager.kt | 23 +++-- .../services/MaintenanceWorkManager.kt | 11 ++- .../settings/extensions/ExtensionsFragment.kt | 85 ++++++++++--------- .../ui/settings/extensions/PluginsFragment.kt | 31 ++++--- .../settings/extensions/PluginsViewModel.kt | 15 +++- .../ui/settings/testing/TestViewModel.kt | 4 +- 6 files changed, 96 insertions(+), 73 deletions(-) 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 32c7e263b05..bbc02eb1efc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -28,11 +28,10 @@ import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.InternalAPI -import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.MainActivity.Companion.lastError -import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.PROVIDER_STATUS_OK import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType @@ -164,8 +163,8 @@ object PluginManager { * This might fix unrecoverable SIGSEGV exceptions when old oat files are loaded in a new app update. */ fun deleteAllOatFiles(context: Context) { - File("${context.filesDir}/${ONLINE_PLUGINS_FOLDER}").listFiles()?.forEach { repo -> - repo.listFiles { file -> file.name == "oat" && file.isDirectory }?.forEach { file -> + File("${context.filesDir}/$ONLINE_PLUGINS_FOLDER").listFiles()?.forEach { repo -> + repo.listFiles { file -> (file.name == "oat") && file.isDirectory }?.forEach { file -> val success = file.deleteRecursively() Log.i(TAG, "Deleted oat directory: ${file.absolutePath} Success=$success") } @@ -207,11 +206,11 @@ object PluginManager { // Maps filepath to plugin val plugins: MutableMap = - LinkedHashMap() + LinkedHashMap() // Maps urls to plugin val urlPlugins: MutableMap = - LinkedHashMap() + LinkedHashMap() private val classLoaders: MutableMap = HashMap() @@ -226,9 +225,15 @@ object PluginManager { val name = file.name if (file.extension == "zip" || file.extension == "cs3") { loadPlugin( - context, - file, - PluginData(name, null, false, file.absolutePath, PLUGIN_VERSION_NOT_SET) + context = context, + file = file, + data = PluginData( + internalName = name, + url = null, + isOnline = false, + filePath = file.absolutePath, + version = PLUGIN_VERSION_NOT_SET + ) ) } else { Log.i(TAG, "Skipping invalid plugin file: $file") diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/MaintenanceWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/MaintenanceWorkManager.kt index fb9713f8138..4382e3efc92 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/MaintenanceWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/MaintenanceWorkManager.kt @@ -19,7 +19,7 @@ class MaintenanceWorkManager(val context: Context, workerParams: WorkerParameter val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) - .setRequiresBatteryNotLow(true) + .setRequiresBatteryNotLow(requiresBatteryNotLow = true) .build() val periodicWork = @@ -40,7 +40,10 @@ class MaintenanceWorkManager(val context: Context, workerParams: WorkerParameter try { // Load all plugins PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context) - PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context, false) + PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins( + context = context, + forceReload = false + ) val apis = APIHolder.allProviders.withLock { APIHolder.allProviders.toTypedArray() } @@ -63,12 +66,12 @@ class MaintenanceWorkManager(val context: Context, workerParams: WorkerParameter pluginSuccess.forEach { (path, success) -> if (!success) { // All providers in this plugin failed - PluginManager.setPluginDisabled(path, true) + PluginManager.setPluginDisabled(path = path, disabled = true) } } return Result.success() - } catch (t: Throwable) { + } catch (ignored: Throwable) { return Result.failure() } } 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 f3e7a25e35e..d106a2f6221 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 @@ -41,7 +41,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.setText class ExtensionsFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentExtensionsBinding::inflate) + BindingCreator.Inflate(FragmentExtensionsBinding::inflate), ) { private val extensionViewModel: ExtensionsViewModel by activityViewModels() @@ -65,7 +65,7 @@ class ExtensionsFragment : BaseFragment( afterRepositoryLoadedEvent -= ::reloadRepositories } - private fun reloadRepositories(success: Boolean = true) { + private fun reloadRepositories(@Suppress("UNUSED_PARAMETER") success: Boolean = true) { extensionViewModel.loadStats() extensionViewModel.loadRepositories() } @@ -132,52 +132,57 @@ class ExtensionsFragment : BaseFragment( } } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> - val dy = scrollY - oldScrollY - if (dy > 0) { // check for scroll down - binding.addRepoButton.shrink() // hide - } else if (dy < -5) { - binding.addRepoButton.extend() // show - } + setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + val dy = scrollY - oldScrollY + if (dy > 0) { // check for scroll down + binding.addRepoButton.shrink() // hide + } else if (dy < -5) { + binding.addRepoButton.extend() // show } } - adapter = RepoAdapter(false, { - findNavController().navigate( - R.id.navigation_settings_extensions_to_navigation_settings_plugins, - PluginsFragment.newInstance( - it.name, - it.url, - false + adapter = RepoAdapter( + isSetup = false, + clickCallback = { + findNavController().navigate( + R.id.navigation_settings_extensions_to_navigation_settings_plugins, + PluginsFragment.newInstance( + it.name, + it.url, + false + ) ) - ) - }, { repo -> - // Prompt user before deleting repo - main { - val uiContext = context ?: binding.root.context - val builder = AlertDialog.Builder(uiContext) - val dialogClickListener = - DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - ioSafe { - RepositoryManager.removeRepository(uiContext.applicationContext, repo) - extensionViewModel.loadStats() - extensionViewModel.loadRepositories() + }, + imageClickCallback = { repo -> + // Prompt user before deleting repo + main { + val uiContext = context ?: binding.root.context + val builder = AlertDialog.Builder(uiContext) + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + ioSafe { + RepositoryManager.removeRepository( + uiContext.applicationContext, + repo + ) + extensionViewModel.loadStats() + extensionViewModel.loadRepositories() + } } - } - DialogInterface.BUTTON_NEGATIVE -> {} + DialogInterface.BUTTON_NEGATIVE -> {} + } } - } - builder.setTitle(R.string.delete_repository) - .setMessage(uiContext.getString(R.string.delete_repository_plugins)) - .setPositiveButton(R.string.delete, dialogClickListener) - .setNegativeButton(R.string.cancel, dialogClickListener) - .show().setDefaultFocus() + builder.setTitle(R.string.delete_repository) + .setMessage(uiContext.getString(R.string.delete_repository_plugins)) + .setPositiveButton(R.string.delete, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show().setDefaultFocus() + } } - }) + ) } observe(extensionViewModel.repositories) { 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 31e9cead202..c1976ef6a64 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 @@ -16,7 +16,6 @@ import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.setRecycledViewPool -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding @@ -35,7 +34,7 @@ const val PLUGINS_BUNDLE_URL = "url" const val PLUGINS_BUNDLE_LOCAL = "isLocal" class PluginsFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentPluginsBinding::inflate) + BindingCreator.Inflate(FragmentPluginsBinding::inflate), ) { private val pluginViewModel: PluginsViewModel by activityViewModels() @@ -69,7 +68,7 @@ class PluginsFragment : BaseFragment( // download all extensions button val downloadAllButton = binding.settingsToolbar.menu?.findItem(R.id.download_all) - if (url == null || name == null) { + if ((url == null) || (name == null)) { dispatchBackPressed() return } @@ -137,17 +136,19 @@ class PluginsFragment : BaseFragment( if (!hasFocus) pluginViewModel.search(null) } - searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String?): Boolean { - pluginViewModel.search(query) - return true - } + searchView?.setOnQueryTextListener( + object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + pluginViewModel.search(query) + return true + } - override fun onQueryTextChange(newText: String?): Boolean { - pluginViewModel.search(newText) - return true + override fun onQueryTextChange(newText: String?): Boolean { + pluginViewModel.search(newText) + return true + } } - }) + ) } // searchView?.onActionViewCollapsed = { // pluginViewModel.search(null) @@ -173,7 +174,7 @@ class PluginsFragment : BaseFragment( }, clickCallback = { pluginViewModel.toggleSelection(it.second.url) - } + }, ) } @@ -218,7 +219,9 @@ class PluginsFragment : BaseFragment( val folderName = names[index] val selected = pluginViewModel.selectedPlugins.toList() val currentFolders = DataStoreHelper.getExtensionFolders().toMutableMap() - val currentList = currentFolders[folderName]?.toMutableList() ?: mutableListOf() + val currentList = + currentFolders[folderName]?.toMutableList() + ?: mutableListOf() currentList.addAll(selected) currentFolders[folderName] = currentList.distinct() DataStoreHelper.setExtensionFolders(currentFolders) 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 61098f7a6a0..59cc69964b1 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 @@ -40,7 +40,7 @@ class PluginsViewModel : ViewModel() { private var plugins: List = emptyList() set(value) { // Also set all the plugin languages for easier filtering - value.map { pluginViewData -> + value.forEach { pluginViewData -> val language = pluginViewData.plugin.second.language?.lowercase() pluginLanguages.add( when { @@ -92,7 +92,14 @@ class PluginsViewModel : ViewModel() { when (action) { BatchAction.Download -> { if (!exists) { - PluginManager.downloadPlugin(activity, metadata.url, metadata.fileHash, metadata.internalName, repo, true) + PluginManager.downloadPlugin( + activity = activity, + pluginUrl = metadata.url, + pluginHash = metadata.fileHash, + internalName = metadata.internalName, + repositoryUrl = repo, + loadPlugin = true + ) } } BatchAction.Delete -> { @@ -292,7 +299,7 @@ class PluginsViewModel : ViewModel() { val plugins = if (repositoryUrl.startsWith("folder://")) { val folderName = repositoryUrl.removePrefix("folder://") - val pluginNames = DataStoreHelper.getExtensionFolders()[folderName] ?: emptyList() + val pluginNames = DataStoreHelper.getExtensionFolders()[folderName] ?: emptyList() (PluginManager.getPluginsOnline() + PluginManager.getPluginsLocal()) .filter { pluginNames.contains(it.internalName) } .map { "" to it.toSitePlugin() } @@ -302,7 +309,7 @@ class PluginsViewModel : ViewModel() { val list = plugins.filter { // Show all non-nsfw plugins or all if nsfw is enabled - it.second.tvTypes?.contains(TvType.NSFW.name) != true || isAdult + (it.second.tvTypes?.contains(TvType.NSFW.name) != true) || isAdult }.map { plugin -> PluginViewData( plugin, 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 302e73c56a5..bea9cbd1133 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 @@ -19,7 +19,7 @@ class TestViewModel : ViewModel() { data class TestProgress( val passed: Int, val failed: Int, - val total: Int + val total: Int, ) enum class ProviderFilter { @@ -127,7 +127,7 @@ class TestViewModel : ViewModel() { fun disableExtensions(paths: List) { viewModelScope.launch { paths.forEach { path -> - PluginManager.setPluginDisabled(path, true) + PluginManager.setPluginDisabled(path = path, disabled = true) } providers.withLock { providers.removeAll { it.first.sourcePlugin in paths } From 88f7264ba88e33c132cb438becbc4186c7e1d113 Mon Sep 17 00:00:00 2001 From: CloudStream User Date: Thu, 4 Jun 2026 11:01:37 +0530 Subject: [PATCH 3/7] Refactor PluginsFragment and fix lint issues --- .../ui/settings/extensions/PluginsFragment.kt | 273 +++++++++--------- 1 file changed, 139 insertions(+), 134 deletions(-) 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 c1976ef6a64..c77954ce70d 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 @@ -7,10 +7,11 @@ import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.databinding.FragmentPluginsBinding -import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.databinding.FragmentPluginsBinding +import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips import com.lagradost.cloudstream3.ui.result.FOCUS_SELF @@ -23,11 +24,9 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setTool 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 const val PLUGINS_BUNDLE_NAME = "name" const val PLUGINS_BUNDLE_URL = "url" @@ -65,8 +64,6 @@ class PluginsFragment : BaseFragment( val name = arguments?.getString(PLUGINS_BUNDLE_NAME) val url = arguments?.getString(PLUGINS_BUNDLE_URL) val isLocal = arguments?.getBoolean(PLUGINS_BUNDLE_LOCAL) == true - // download all extensions button - val downloadAllButton = binding.settingsToolbar.menu?.findItem(R.id.download_all) if ((url == null) || (name == null)) { dispatchBackPressed() @@ -75,6 +72,18 @@ class PluginsFragment : BaseFragment( setToolBarScrollFlags() setUpToolbar(name) + setupToolbar(binding, url) + setupRecyclerView(binding, url, isLocal) + setupSelectionToolbar(binding) + + if (isLocal) { + setupLocalMode(binding, url) + } else { + setupRemoteMode(binding, url) + } + } + + private fun setupToolbar(binding: FragmentPluginsBinding, url: String) { binding.settingsToolbar.apply { setOnMenuItemClickListener { menuItem -> when (menuItem?.itemId) { @@ -83,37 +92,7 @@ class PluginsFragment : BaseFragment( } R.id.lang_filter -> { - val languagesTagName = pluginViewModel.pluginLanguages - .map { langTag -> - Pair( - langTag, - getNameNextToFlagEmoji(langTag) ?: langTag - ) - } - .sortedBy { - it.second.substringAfter("\u00a0").lowercase() - } // name ignoring flag emoji - .toMutableList() - - // Move "none" to 1st position as it's special code to indicate unknown/missing language - if (languagesTagName.remove(Pair("none", "none"))) { - languagesTagName.add(0, Pair("none", getString(R.string.no_data))) - } - - val currentIndexList = pluginViewModel.selectedLanguages.map { langTag -> - languagesTagName.indexOfFirst { lang -> lang.first == langTag } - } - - activity?.showMultiDialog( - languagesTagName.map { it.second }, - currentIndexList, - getString(R.string.provider_lang_settings), - {} - ) { selectedList -> - pluginViewModel.selectedLanguages = - selectedList.map { languagesTagName[it].first } - pluginViewModel.updateFilteredPlugins() - } + showLanguageFilter() } else -> {} @@ -121,10 +100,7 @@ class PluginsFragment : BaseFragment( return@setOnMenuItemClickListener true } - val searchView = - menu?.findItem(R.id.search_button)?.actionView as? SearchView - - // Don't go back if active query + val searchView = menu?.findItem(R.id.search_button)?.actionView as? SearchView setNavigationOnClickListener { if (searchView?.isIconified == false) { searchView.isIconified = true @@ -150,12 +126,42 @@ class PluginsFragment : BaseFragment( } ) } -// searchView?.onActionViewCollapsed = { -// pluginViewModel.search(null) -// } + } - // Because onActionViewCollapsed doesn't wanna work we need this workaround :( + private fun showLanguageFilter() { + val languagesTagName = pluginViewModel.pluginLanguages + .map { langTag -> + Pair( + langTag, + getNameNextToFlagEmoji(langTag) ?: langTag + ) + } + .sortedBy { + it.second.substringAfter("\u00a0").lowercase() + } + .toMutableList() + if (languagesTagName.remove(Pair("none", "none"))) { + languagesTagName.add(0, Pair("none", getString(R.string.no_data))) + } + + val currentIndexList = pluginViewModel.selectedLanguages.map { langTag -> + languagesTagName.indexOfFirst { lang -> lang.first == langTag } + } + + activity?.showMultiDialog( + languagesTagName.map { it.second }, + currentIndexList, + getString(R.string.provider_lang_settings), + {} + ) { selectedList -> + pluginViewModel.selectedLanguages = + selectedList.map { languagesTagName[it].first } + pluginViewModel.updateFilteredPlugins() + } + } + + private fun setupRecyclerView(binding: FragmentPluginsBinding, url: String, isLocal: Boolean) { binding.pluginRecyclerView.apply { setLinearListLayout( isHorizontal = false, @@ -163,19 +169,18 @@ class PluginsFragment : BaseFragment( nextRight = FOCUS_SELF, ) setRecycledViewPool(PluginAdapter.sharedPool) - adapter = - PluginAdapter( - iconClickCallback = { - pluginViewModel.handlePluginAction(activity, url, it, isLocal) - }, - longClickCallback = { - pluginViewModel.toggleSelectionMode(true) - pluginViewModel.toggleSelection(it.second.url) - }, - clickCallback = { - pluginViewModel.toggleSelection(it.second.url) - }, - ) + adapter = PluginAdapter( + iconClickCallback = { + pluginViewModel.handlePluginAction(activity, url, it, isLocal) + }, + longClickCallback = { + pluginViewModel.toggleSelectionMode(enabled = true) + pluginViewModel.toggleSelection(it.second.url) + }, + clickCallback = { + pluginViewModel.toggleSelection(it.second.url) + }, + ) } observe(pluginViewModel.filteredPlugins) { (scrollToTop, list) -> @@ -183,7 +188,7 @@ 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 } @@ -191,86 +196,95 @@ class PluginsFragment : BaseFragment( title = "$selectedCount Selected" } } + } + private fun setupSelectionToolbar(binding: FragmentPluginsBinding) { binding.selectionToolbar.apply { inflateMenu(R.menu.plugin_selection) setNavigationOnClickListener { - pluginViewModel.toggleSelectionMode(false) + pluginViewModel.toggleSelectionMode(enabled = 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) - } + handleBatchAction(menuItem.itemId) 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( - filterDisabled = url == "disabled", - folderName = if (url.startsWith("folder://")) url.removePrefix("folder://") else null - ) + private fun handleBatchAction(itemId: Int) { + val action = when (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 -> { + showMoveToFolderDialog() + null + } + else -> null + } + action?.let { + pluginViewModel.batchAction(activity, it) + } + } - binding.tvtypesChipsScroll.root.isVisible = false + private fun showMoveToFolderDialog() { + val folders = DataStoreHelper.getExtensionFolders() + if (folders.isEmpty()) { + showToast("Create a folder first in the Extensions screen") } else { - pluginViewModel.updatePluginList(context, url) - binding.tvtypesChipsScroll.root.isVisible = true - // not needed for users but may be useful for devs - downloadAllButton?.isVisible = BuildConfig.DEBUG - - bindChips( - binding.tvtypesChipsScroll.tvtypesChips, - emptyList(), - TvType.entries.toList(), - callback = { list -> - pluginViewModel.tvTypes.clear() - pluginViewModel.tvTypes.addAll(list.map { it.name }) - pluginViewModel.updateFilteredPlugins() - }, - nextFocusDown = R.id.plugin_recycler_view, - nextFocusUp = null, - ) + val names = folders.keys.toList() + activity?.showDialog( + names, + -1, + "Move to Folder", + showApply = true, + dismissCallback = {} + ) { 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(enabled = false) + } } } + private fun setupLocalMode(binding: FragmentPluginsBinding, url: String) { + binding.settingsToolbar.menu?.findItem(R.id.download_all)?.isVisible = false + binding.settingsToolbar.menu?.findItem(R.id.lang_filter)?.isVisible = false + pluginViewModel.updatePluginListLocal( + filterDisabled = url == "disabled", + folderName = if (url.startsWith("folder://")) url.removePrefix("folder://") else null + ) + binding.tvtypesChipsScroll.root.isVisible = false + } + + private fun setupRemoteMode(binding: FragmentPluginsBinding, url: String) { + pluginViewModel.updatePluginList(context, url) + binding.tvtypesChipsScroll.root.isVisible = true + binding.settingsToolbar.menu?.findItem(R.id.download_all)?.isVisible = BuildConfig.DEBUG + + bindChips( + binding.tvtypesChipsScroll.tvtypesChips, + emptyList(), + TvType.entries.toList(), + callback = { list -> + pluginViewModel.tvTypes.clear() + pluginViewModel.tvTypes.addAll(list.map { it.name }) + pluginViewModel.updateFilteredPlugins() + }, + nextFocusDown = R.id.plugin_recycler_view, + nextFocusUp = null, + ) + } + companion object { fun newInstance(name: String, url: String, isLocal: Boolean): Bundle { return Bundle().apply { @@ -279,14 +293,5 @@ class PluginsFragment : BaseFragment( putBoolean(PLUGINS_BUNDLE_LOCAL, isLocal) } } - -// class RepoSearchView(context: Context) : android.widget.SearchView(context) { -// var onActionViewCollapsed = {} -// -// override fun onActionViewCollapsed() { -// onActionViewCollapsed() -// } -// } - } -} \ No newline at end of file +} From 511b840512729c41463917b9f322e3d78594cfec Mon Sep 17 00:00:00 2001 From: CloudStream User Date: Thu, 4 Jun 2026 11:35:52 +0530 Subject: [PATCH 4/7] Cleanup unnecessary parentheses for CodeFactor --- .../java/com/lagradost/cloudstream3/plugins/PluginManager.kt | 2 +- .../cloudstream3/ui/settings/extensions/PluginsFragment.kt | 2 +- .../cloudstream3/ui/settings/extensions/PluginsViewModel.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 bbc02eb1efc..3108eb1f9b3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -164,7 +164,7 @@ object PluginManager { */ fun deleteAllOatFiles(context: Context) { File("${context.filesDir}/$ONLINE_PLUGINS_FOLDER").listFiles()?.forEach { repo -> - repo.listFiles { file -> (file.name == "oat") && file.isDirectory }?.forEach { file -> + repo.listFiles { file -> file.name == "oat" && file.isDirectory }?.forEach { file -> val success = file.deleteRecursively() Log.i(TAG, "Deleted oat directory: ${file.absolutePath} Success=$success") } 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 c77954ce70d..744ab09dc32 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 @@ -65,7 +65,7 @@ class PluginsFragment : BaseFragment( val url = arguments?.getString(PLUGINS_BUNDLE_URL) val isLocal = arguments?.getBoolean(PLUGINS_BUNDLE_LOCAL) == true - if ((url == null) || (name == null)) { + if (url == null || name == null) { dispatchBackPressed() return } 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 59cc69964b1..0c5322698a4 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 @@ -309,7 +309,7 @@ class PluginsViewModel : ViewModel() { val list = plugins.filter { // Show all non-nsfw plugins or all if nsfw is enabled - (it.second.tvTypes?.contains(TvType.NSFW.name) != true) || isAdult + it.second.tvTypes?.contains(TvType.NSFW.name) != true || isAdult }.map { plugin -> PluginViewData( plugin, From 89287f14cfba5218d43a60b38f93a19216f0db15 Mon Sep 17 00:00:00 2001 From: CloudStream User Date: Thu, 4 Jun 2026 12:00:45 +0530 Subject: [PATCH 5/7] Add search result sorting by provider (Issue #2869) --- .../cloudstream3/ui/search/SearchFragment.kt | 17 +++++++ .../cloudstream3/ui/search/SearchViewModel.kt | 51 ++++++++++++++----- .../cloudstream3/utils/DataStoreHelper.kt | 2 + app/src/main/res/layout/fragment_search.xml | 11 ++++ app/src/main/res/values/strings.xml | 2 + 5 files changed, 70 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt index 5f5b064b543..30d3f8c668d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt @@ -74,6 +74,8 @@ 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.DataStoreHelper.currentAccount +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback @@ -276,6 +278,21 @@ class SearchFragment : BaseFragment( } } + binding.searchSort.setOnClickListener { + val methods = SearchSortMethod.entries + val names = methods.map { getString(it.stringRes) } + activity?.showDialog( + names, + DataStoreHelper.searchSortMethod, + getString(R.string.sort), + false, + {} + ) { index -> + DataStoreHelper.searchSortMethod = index + search(binding.mainSearch.query?.toString()) + } + } + val searchExitIcon = binding.mainSearch.findViewById(androidx.appcompat.R.id.search_close_btn) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt index f60588e35cd..d024df9c11b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys @@ -18,6 +19,7 @@ import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -30,6 +32,11 @@ data class ExpandableSearchList( var list: List, var currentPage: Int, var hasNext: Boolean, ) +enum class SearchSortMethod(val stringRes: Int) { + Interlaced(R.string.search_sort_interlaced), + ByProvider(R.string.search_sort_by_provider) +} + const val SEARCH_HISTORY_KEY = "search_history" class SearchViewModel : ViewModel() { @@ -174,22 +181,40 @@ class SearchViewModel : ViewModel() { return lists.values.first() } + val sortMethod = try { + SearchSortMethod.entries[DataStoreHelper.searchSortMethod] + } catch (e: Exception) { + SearchSortMethod.Interlaced + } + val list = ArrayList() - val nestedList = - lists.map { it.value.list } - - // I do it this way to move the relevant search results to the top - var index = 0 - while (true) { - var added = 0 - for (sublist in nestedList) { - if (sublist.size > index) { - list.add(sublist[index]) - added++ + + if (sortMethod == SearchSortMethod.ByProvider) { + val pinnedOrder = DataStoreHelper.pinnedProviders.reversedArray() + val sortedKeys = lists.keys.sortedWith(compareBy { providerName -> + val index = pinnedOrder.indexOf(providerName) + if (index == -1) Int.MAX_VALUE else index + }) + for (key in sortedKeys) { + lists[key]?.list?.let { list.addAll(it) } + } + } else { + val nestedList = + lists.map { it.value.list } + + // I do it this way to move the relevant search results to the top + var index = 0 + while (true) { + var added = 0 + for (sublist in nestedList) { + if (sublist.size > index) { + list.add(sublist[index]) + added++ + } } + if (added == 0) break + index++ } - if (added == 0) break - index++ } return ExpandableSearchList(list, 1, false) 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 cfad1e5680c..a3db2fb96b2 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,7 @@ 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 SEARCH_SORT_METHOD = "search_sort_method" const val SOURCE_PREFERENCE = "source_preference" const val EXTENSION_FOLDERS = "extension_folders" const val GLOBAL_PROGRESS = "global_progress" @@ -136,6 +137,7 @@ object DataStoreHelper { IntArray(0) ) var playBackSpeed: Float by UserPreferenceDelegate("playback_speed", 1.0f) + var searchSortMethod: Int by UserPreferenceDelegate(SEARCH_SORT_METHOD, 0) var resizeMode: Int by UserPreferenceDelegate("resize_mode", 0) var librarySortingMode: Int by UserPreferenceDelegate( "library_sorting_mode", diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml index 408460d41d3..06753146ddc 100644 --- a/app/src/main/res/layout/fragment_search.xml +++ b/app/src/main/res/layout/fragment_search.xml @@ -38,6 +38,17 @@ android:src="@drawable/ic_mic" app:tint="?attr/textColor" /> + + Storage permissions missing. Please try again. Error backing up %s Search + Default (Interlaced) + By Provider Library Accounts and Security Updates and Backup From 85fd1a8487733fdb43f451f9a3ecf9d2d57e5cae Mon Sep 17 00:00:00 2001 From: CloudStream User Date: Thu, 4 Jun 2026 12:25:34 +0530 Subject: [PATCH 6/7] Fix critical bugs: Search Crash (#2865), Splash Screen Freeze (#1564), and Player Preview Lag (#2823) --- .../com/lagradost/cloudstream3/MainActivity.kt | 4 +++- .../cloudstream3/ui/player/PreviewGenerator.kt | 6 +++--- .../cloudstream3/ui/search/SearchViewModel.kt | 14 ++++++++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 8f672553370..3f55f9c5493 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -1224,7 +1224,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } safe { // Recompile oat on new version - PluginManager.deleteAllOatFiles(this) + ioSafe { + PluginManager.deleteAllOatFiles(this@MainActivity) + } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt index 2893bcc47fd..8ab8d8f9f1e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt @@ -6,7 +6,6 @@ import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build import android.util.Log -import androidx.annotation.WorkerThread import androidx.core.graphics.scale import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.CloudStreamApp @@ -378,6 +377,7 @@ private class M3u8PreviewGenerator(override var params: ImageParams) : IPreviewG continue } Log.i(TAG, "Generating preview for $index") + kotlinx.coroutines.delay(10) // Give UI time to breathe val ts = hsl.allTsLinks[index] try { @@ -506,8 +506,7 @@ private class Mp4PreviewGenerator(override var params: ImageParams) : IPreviewGe override var durationMs: Long = 0L @Throws - @WorkerThread - private fun start(scope: CoroutineScope) { + private suspend fun start(scope: CoroutineScope) { Log.i(TAG, "Started loading preview") val durationMs = @@ -528,6 +527,7 @@ private class Mp4PreviewGenerator(override var params: ImageParams) : IPreviewGe // frame = 100 / 2^lod + i * 100 / 2^(lod-1) = duration % where lod is one indexed val fraction = (1.0f.div((1 shl l).toFloat()) + i * 1.0f.div(items.toFloat())) Log.i(TAG, "Generating preview for ${fraction * 100}%") + kotlinx.coroutines.delay(10) // Give UI time to breathe val frame = durationUs * fraction val img = retriever.image(frame.toLong(), params) if (!scope.isActive) return diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt index d024df9c11b..b5168f14ea5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap data class ExpandableSearchList( @@ -69,7 +70,7 @@ class SearchViewModel : ViewModel() { /** Save which providers can searched again and which search result page they are on. * Maps provider name to search list. * @see [HomeViewModel.expandable] */ - private val expandableSearches: MutableMap = mutableMapOf() + private val expandableSearches: ConcurrentHashMap = ConcurrentHashMap() private var currentSearchIndex = 0 private var onGoingSearch: Job? = null @@ -176,31 +177,32 @@ class SearchViewModel : ViewModel() { ) } - private fun bundleSearch(lists: MutableMap): ExpandableSearchList { + private fun bundleSearch(lists: Map): ExpandableSearchList { if (lists.size == 1) { return lists.values.first() } val sortMethod = try { SearchSortMethod.entries[DataStoreHelper.searchSortMethod] - } catch (e: Exception) { + } catch (_: Exception) { SearchSortMethod.Interlaced } val list = ArrayList() + val currentLists = lists.toMap() // Snapshot for thread safety if (sortMethod == SearchSortMethod.ByProvider) { val pinnedOrder = DataStoreHelper.pinnedProviders.reversedArray() - val sortedKeys = lists.keys.sortedWith(compareBy { providerName -> + val sortedKeys = currentLists.keys.sortedWith(compareBy { providerName -> val index = pinnedOrder.indexOf(providerName) if (index == -1) Int.MAX_VALUE else index }) for (key in sortedKeys) { - lists[key]?.list?.let { list.addAll(it) } + currentLists[key]?.list?.let { list.addAll(it) } } } else { val nestedList = - lists.map { it.value.list } + currentLists.map { it.value.list } // I do it this way to move the relevant search results to the top var index = 0 From 928ae85fa5bac85e71ca63ebf204d8bc698d21fa Mon Sep 17 00:00:00 2001 From: CloudStream User Date: Thu, 4 Jun 2026 13:08:44 +0530 Subject: [PATCH 7/7] Fix Page 2 bugs: Cloned sites disappearing (#1273) and Open in Browser loop (#2376) --- .../lagradost/cloudstream3/MainActivity.kt | 3 +- .../cloudstream3/utils/AppContextUtils.kt | 34 +++++++++++-------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 3f55f9c5493..93a4f30cbc5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -638,7 +638,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa override fun onResume() { super.onResume() - afterPluginsLoadedEvent += ::onAllPluginsLoaded setActivityInstance(this) try { if (isCastApiAvailable()) { @@ -723,7 +722,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa broadcastIntent.action = "restart_service" broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java) this.sendBroadcast(broadcastIntent) - afterPluginsLoadedEvent -= ::onAllPluginsLoaded detachBackPressedCallback("MainActivityDefault") super.onDestroy() } @@ -1198,6 +1196,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa setNavigationBarColorCompat(R.attr.primaryGrayBackground) updateLocale() super.onCreate(savedInstanceState) + afterPluginsLoadedEvent += ::onAllPluginsLoaded MaintenanceWorkManager.enqueuePeriodicWork(this) try { if (isCastApiAvailable()) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index 7278fcdd74f..44a04658c33 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -594,20 +594,26 @@ object AppContextUtils { intent.data = url.toUri() intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - // activityResultRegistry is used to fall back to webview if a browser is missing - // On older versions the startActivity just crashes, but on newer android versions - // You need to check the result to make sure it failed - val activityResultRegistry = fragment?.activity?.activityResultRegistry - if (activityResultRegistry != null) { - activityResultRegistry.register( - url, - ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == RESULT_CANCELED && fallbackWebview) { - openWebView(fragment, url) - } - }.launch(intent) - } else this.startActivity(intent) + // Feature Fix: Exclude CloudStream from the browser intent to avoid loop (#2376) + val resolveInfo = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + val filteredIntents = resolveInfo.filter { it.activityInfo.packageName != packageName } + .map { info -> + val finalIntent = Intent(intent) + finalIntent.setPackage(info.activityInfo.packageName) + finalIntent + } + + if (filteredIntents.isNotEmpty()) { + val chooser = Intent.createChooser(filteredIntents.first(), getString(R.string.browser)) + if (filteredIntents.size > 1) { + chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, filteredIntents.drop(1).toTypedArray()) + } + this.startActivity(chooser) + } else if (fallbackWebview) { + openWebView(fragment, url) + } else { + this.startActivity(intent) // Fallback to normal behavior + } } catch (e: Exception) { logError(e) if (fallbackWebview) {