diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 41e8fc0a01a..5cb84297038 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,8 +7,10 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { alias(libs.plugins.android.application) - alias(libs.plugins.dokka) alias(libs.plugins.kotlin.android) + // alias(libs.plugins.dokka) + + // alias(libs.plugins.google.services) // We use manual Firebase initialization } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) @@ -217,6 +219,18 @@ dependencies { // Downloading & Networking implementation(libs.work.runtime.ktx) implementation(libs.nicehttp) // HTTP Lib + + // Firebase + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.firestore) + implementation(libs.firebase.auth) + + configurations.all { + resolutionStrategy { + force("com.google.protobuf:protobuf-javalite:3.25.1") + } + exclude(group = "com.google.protobuf", module = "protobuf-java") + } implementation(project(":library") { // There does not seem to be a good way of getting the android flavor. @@ -269,6 +283,7 @@ tasks.withType { } } +/* dokka { moduleName = "App" dokkaSourceSets { @@ -287,3 +302,4 @@ dokka { } } } +*/ diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt index 80f084b08f0..753f17a44e2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -2,9 +2,9 @@ package com.lagradost.cloudstream3 import android.content.Context import com.lagradost.api.setContext -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.removeKeys -import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.getKey +import com.lagradost.cloudstream3.utils.removeKeys +import com.lagradost.cloudstream3.utils.setKey import java.lang.ref.WeakReference /** diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt index b7832799884..55dc980975c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt @@ -21,11 +21,12 @@ import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.getKeys -import com.lagradost.cloudstream3.utils.DataStore.removeKey -import com.lagradost.cloudstream3.utils.DataStore.removeKeys -import com.lagradost.cloudstream3.utils.DataStore.setKey + +import com.lagradost.cloudstream3.utils.getKey +import com.lagradost.cloudstream3.utils.getKeys +import com.lagradost.cloudstream3.utils.removeKey +import com.lagradost.cloudstream3.utils.removeKeys +import com.lagradost.cloudstream3.utils.setKey import com.lagradost.cloudstream3.utils.ImageLoader.buildImageLoader import kotlinx.coroutines.runBlocking import java.io.File @@ -127,12 +128,12 @@ class CloudStreamApp : Application(), SingletonImageLoader.Factory { return context?.removeKeys(folder) } - fun setKey(path: String, value: T) { - context?.setKey(path, value) + fun setKey(path: String, value: T, commit: Boolean = false) { + context?.setKey(path, value, commit) } - fun setKey(folder: String, path: String, value: T) { - context?.setKey(folder, path, value) + fun setKey(folder: String, path: String, value: T, commit: Boolean = false) { + context?.setKey(folder, path, value, commit) } inline fun getKey(path: String, defVal: T?): T? { diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 1caaaa4c693..ef97ddc744a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -24,6 +24,8 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.Toast import androidx.activity.result.ActivityResultLauncher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import androidx.annotation.IdRes import androidx.annotation.MainThread import androidx.appcompat.app.AlertDialog @@ -40,6 +42,7 @@ import androidx.core.view.isVisible import androidx.core.view.marginStart import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy @@ -93,6 +96,7 @@ import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver import com.lagradost.cloudstream3.services.SubscriptionWorkManager import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_PLAYER import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_REPO @@ -138,6 +142,7 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult +import com.lagradost.cloudstream3.utils.FirestoreSyncManager import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback @@ -152,8 +157,8 @@ import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.getKey +import com.lagradost.cloudstream3.utils.setKey import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching @@ -204,6 +209,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa const val ANIMATED_OUTLINE: Boolean = false var lastError: String? = null + + private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY" const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY" @@ -256,11 +263,18 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa */ val reloadLibraryEvent = Event() + /** + * Used by FirestoreSyncManager to notify UI about incoming data changes (e.g. pending plugins) + */ + val syncUpdatedEvent = Event() + /** * Used by DataStoreHelper to fully reload Navigation Rail header picture */ val reloadAccountEvent = Event() + + /** * @return true if the str has launched an app task (be it successful or not) * @param isWebview does not handle providers and opening download page if true. Can still add repos and login. @@ -620,6 +634,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa override fun onResume() { super.onResume() + if (FirestoreSyncManager.isEnabled(this)) { + ioSafe { FirestoreSyncManager.pushAllLocalData(this@MainActivity) } + } afterPluginsLoadedEvent += ::onAllPluginsLoaded setActivityInstance(this) try { @@ -633,7 +650,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa override fun onPause() { super.onPause() - + if (FirestoreSyncManager.isEnabled(this)) { + ioSafe { FirestoreSyncManager.pushAllLocalData(this@MainActivity) } + } // Start any delayed updates if (ApkInstaller.delayedInstaller?.startInstallation() == true) { Toast.makeText(this, R.string.update_started, Toast.LENGTH_LONG).show() @@ -821,6 +840,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } } + // Signal FirestoreSyncManager that plugins are ready, + // so any deferred remote plugin data can be applied safely. + FirestoreSyncManager.onPluginsReady(this@MainActivity) } } @@ -1191,6 +1213,20 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } catch (t: Throwable) { logError(t) } + + + + lifecycleScope.launch(Dispatchers.IO) { + FirestoreSyncManager.initialize(this@MainActivity) + } + + afterPluginsLoadedEvent += { + FirestoreSyncManager.onPluginsReady(this) + } + + mainPluginsLoadedEvent += { + FirestoreSyncManager.onPluginsReady(this) + } window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) updateTv() @@ -1349,6 +1385,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ) } else { ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(this@MainActivity) + afterPluginsLoadedEvent.invoke(false) } //Automatically download not existing plugins, using mode specified. @@ -1358,19 +1395,31 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa 0 ) ) ?: AutoDownloadMode.Disable - if (autoDownloadPlugin != AutoDownloadMode.Disable) { - PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( - this@MainActivity, - autoDownloadPlugin - ) + try { + if (autoDownloadPlugin != AutoDownloadMode.Disable) { + PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( + this@MainActivity, + autoDownloadPlugin + ) + } + } catch (e: Exception) { + main { showToast(txt("Online Plugin Load Failed: ${e.message}"), Toast.LENGTH_LONG) } + e.printStackTrace() } + main { FirestoreSyncManager.onPluginsReady(this@MainActivity) } } ioSafe { - PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins( - this@MainActivity, - false - ) + try { + PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins( + this@MainActivity, + false + ) + } catch (e: Exception) { + main { showToast(txt("Local Plugin Load Failed: ${e.message}"), Toast.LENGTH_LONG) } + e.printStackTrace() + } + main { FirestoreSyncManager.onPluginsReady(this@MainActivity) } } // Add your channel creation here @@ -1653,6 +1702,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa val navController = navHostFragment.navController navController.addOnDestinationChangedListener { _: NavController, navDestination: NavDestination, bundle: Bundle? -> + if (FirestoreSyncManager.isEnabled(this@MainActivity)) { + FirestoreSyncManager.syncNow(this@MainActivity) + } // Intercept search and add a query updateNavBar(navDestination) if (navDestination.matchDestination(R.id.navigation_search) && !nextSearchQuery.isNullOrBlank()) { @@ -2032,6 +2084,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa updateLocale() runDefault() } + } /** Biometric stuff **/ 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 1b5d2909c3f..1381458e250 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -53,6 +53,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename import com.lagradost.cloudstream3.utils.extractorApis +import com.lagradost.cloudstream3.utils.FirestoreSyncManager import com.lagradost.cloudstream3.utils.txt import dalvik.system.PathClassLoader import kotlinx.coroutines.sync.Mutex @@ -75,6 +76,8 @@ data class PluginData( @JsonProperty("isOnline") val isOnline: Boolean, @JsonProperty("filePath") val filePath: String, @JsonProperty("version") val version: Int, + @JsonProperty("addedDate") val addedDate: Long = 0, + @JsonProperty("isDeleted") val isDeleted: Boolean = false, ) { fun toSitePlugin(): SitePlugin { return SitePlugin( @@ -109,18 +112,34 @@ object PluginManager { private var hasCreatedNotChanel = false + /** + * Store data about the plugin for fetching later + * */ + fun getPluginsOnline(includeDeleted: Boolean = false): Array { + return (getKey>(PLUGINS_KEY) ?: emptyArray()).filter { includeDeleted || !it.isDeleted }.toTypedArray() + } + + // Helper for internal use to preserve tombstones + fun getPluginsOnlineRaw(): Array { + return getKey>(PLUGINS_KEY) ?: emptyArray() + } + /** * Store data about the plugin for fetching later * */ private suspend fun setPluginData(data: PluginData) { lock.withLock { if (data.isOnline) { - val plugins = getPluginsOnline() - val newPlugins = plugins.filter { it.filePath != data.filePath } + data - setKey(PLUGINS_KEY, newPlugins) + val plugins = getPluginsOnlineRaw() + // Update or Add: filter out old entry (by filePath or internalName?) + // filePath is unique per install. + // We want to keep others, and replace THIS one. + val newPlugins = (plugins.filter { it.filePath != data.filePath } + data.copy(isDeleted = false, addedDate = System.currentTimeMillis())).toTypedArray() + setKey(PLUGINS_KEY, newPlugins, commit = true) } else { val plugins = getPluginsLocal() - setKey(PLUGINS_KEY_LOCAL, plugins.filter { it.filePath != data.filePath } + data) + val newPlugins = (plugins.filter { it.filePath != data.filePath } + data).toTypedArray() + setKey(PLUGINS_KEY_LOCAL, newPlugins, commit = true) } } } @@ -129,25 +148,35 @@ object PluginManager { if (data == null) return lock.withLock { if (data.isOnline) { - val plugins = getPluginsOnline().filter { it.url != data.url } - setKey(PLUGINS_KEY, plugins) + val plugins = getPluginsOnlineRaw() + // Mark as deleted (Tombstone) + val newPlugins = plugins.map { + if (it.filePath == data.filePath) it.copy(isDeleted = true, addedDate = System.currentTimeMillis()) else it + } + setKey(PLUGINS_KEY, newPlugins) } else { - val plugins = getPluginsLocal().filter { it.filePath != data.filePath } - setKey(PLUGINS_KEY_LOCAL, plugins) + val plugins = getPluginsLocal().filter { it.filePath != data.filePath }.toTypedArray() + setKey(PLUGINS_KEY_LOCAL, plugins, commit = true) } } } suspend fun deleteRepositoryData(repositoryPath: String) { lock.withLock { - val plugins = getPluginsOnline().filter { - !it.filePath.contains(repositoryPath) - } - val file = File(repositoryPath) - safe { - if (file.exists()) file.deleteRecursively() + val plugins = getPluginsOnlineRaw() + // Mark all plugins in this repo as deleted + val newPlugins = plugins.map { + if (it.filePath.contains(repositoryPath)) it.copy(isDeleted = true, addedDate = System.currentTimeMillis()) else it } - setKey(PLUGINS_KEY, plugins) + // Logic to actually delete files handled by caller (removeRepository)? + // removeRepository calls: safe { file.deleteRecursively() } + // So files are gone. We just update the list. + // But removeRepository also calls unloadPlugin... + + // Wait, removeRepository calls PluginManager.deleteRepositoryData(file.absolutePath) + // It also deletes the directory. + // So we just need to update the Key. + setKey(PLUGINS_KEY, newPlugins.toTypedArray(), commit = true) } } @@ -165,12 +194,10 @@ object PluginManager { } - fun getPluginsOnline(): Array { - return getKey(PLUGINS_KEY) ?: emptyArray() - } - fun getPluginsLocal(): Array { - return getKey(PLUGINS_KEY_LOCAL) ?: emptyArray() + + fun getPluginsLocal(includeDeleted: Boolean = false): Array { + return (getKey>(PLUGINS_KEY_LOCAL) ?: emptyArray()).filter { includeDeleted || !it.isDeleted }.toTypedArray() } private val CLOUD_STREAM_FOLDER = @@ -197,18 +224,6 @@ object PluginManager { var loadedOnlinePlugins = false private set - private suspend fun maybeLoadPlugin(context: Context, file: File) { - val name = file.name - if (file.extension == "zip" || file.extension == "cs3") { - loadPlugin( - context, - file, - PluginData(name, null, false, file.absolutePath, PLUGIN_VERSION_NOT_SET) - ) - } else { - Log.i(TAG, "Skipping invalid plugin file: $file") - } - } // Helper class for updateAllOnlinePluginsAndLoadThem @@ -296,7 +311,7 @@ object PluginManager { val updatedPlugins = mutableListOf() - outdatedPlugins.amap { pluginData -> + outdatedPlugins.forEach { pluginData -> if (pluginData.isDisabled) { //updatedPlugins.add(activity.getString(R.string.single_plugin_disabled, pluginData.onlineData.second.name)) unloadPlugin(pluginData.savedData.filePath) @@ -360,14 +375,16 @@ object PluginManager { }.flatten().distinctBy { it.second.url } val providerLang = activity.getApiProviderLangSettings() - //Log.i(TAG, "providerLang => ${providerLang.toJson()}") + + // Get the list of plugins that SHOULD be installed (synced from cloud) + val targetPlugins = getPluginsOnline().map { it.internalName }.toSet() // Iterate online repos and returns not downloaded plugins val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData -> val sitePlugin = onlineData.second val tvtypes = sitePlugin.tvTypes ?: listOf() - //Don't include empty urls + // Don't include empty urls if (sitePlugin.url.isBlank()) { return@mapNotNull null } @@ -375,12 +392,17 @@ object PluginManager { return@mapNotNull null } - //Omit already existing plugins + // Omit already existing plugins if (getPluginPath(activity, sitePlugin.internalName, onlineData.first).exists()) { Log.i(TAG, "Skip > ${sitePlugin.internalName}") return@mapNotNull null } + // FILTER: Only download plugins that are in our synced list + if (!targetPlugins.contains(sitePlugin.internalName)) { + return@mapNotNull null + } + //Omit non-NSFW if mode is set to NSFW only if (mode == AutoDownloadMode.NsfwOnly) { if (!tvtypes.contains(TvType.NSFW.name)) { @@ -438,6 +460,7 @@ object PluginManager { // } Log.i(TAG, "Plugin download done!") + loadedOnlinePlugins = true } @Throws @@ -459,18 +482,40 @@ object PluginManager { replaceWith = ReplaceWith("loadPlugin"), level = DeprecationLevel.ERROR ) - @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) { assertNonRecursiveCallstack() - // Load all plugins as fast as possible! - (getPluginsOnline()).toList().amap { pluginData -> + // Migration: Move plugins that were incorrectly stored in PLUGINS_KEY_LOCAL + // (with isOnline=false) back to PLUGINS_KEY where they belong. + // A plugin with a non-null URL was downloaded from a repo and should be online. + val localPlugins = getPluginsLocal() + val misplacedPlugins = localPlugins.filter { it.url != null } + if (misplacedPlugins.isNotEmpty()) { + Log.i(TAG, "Migrating ${misplacedPlugins.size} misplaced plugins from LOCAL to ONLINE key") + lock.withLock { + val onlinePlugins = getPluginsOnlineRaw().toMutableList() + misplacedPlugins.forEach { plugin -> + val migrated = plugin.copy(isOnline = true) + // Remove from online list if already exists (by filePath) + onlinePlugins.removeAll { it.filePath == migrated.filePath } + onlinePlugins.add(migrated) + } + setKey(PLUGINS_KEY, onlinePlugins.toTypedArray(), commit = true) + // Remove migrated plugins from local list + val remainingLocal = localPlugins.filter { it.url == null }.toTypedArray() + setKey(PLUGINS_KEY_LOCAL, remainingLocal, commit = true) + } + } + + // Load all plugins sequentially to avoid DataStore race conditions! + getPluginsOnline().toList().forEach { pluginData -> loadPlugin( context, File(pluginData.filePath), pluginData ) } + loadedOnlinePlugins = true } /** @@ -520,6 +565,7 @@ object PluginManager { val res = dir.mkdirs() if (!res) { Log.w(TAG, "Failed to create local directories") + loadedLocalPlugins = true // Mark as ready anyway so sync isn't blocked return } } @@ -537,10 +583,12 @@ object PluginManager { pluginDirectory.mkdirs() // Ensure the plugins directory exists } - // Make sure all local plugins are fully refreshed. - removeKey(PLUGINS_KEY_LOCAL) + // Make sure all local plugins are fully refreshed but preserve their metadata (like URLs) + // We no longer removeKey(PLUGINS_KEY_LOCAL) here because it's destructive. + // Instead, maybeLoadPlugin will merge/update existing entries. - sortedPlugins?.sortedBy { it.name }?.amap { file -> + val localPluginsToSet = mutableListOf() + sortedPlugins?.sortedBy { it.name }?.forEach { file -> try { val destinationFile = File(pluginDirectory, file.name) @@ -561,12 +609,32 @@ object PluginManager { } // Load the plugin after it has been copied - maybeLoadPlugin(context, destinationFile) + val name = destinationFile.name + if (destinationFile.extension == "zip" || destinationFile.extension == "cs3") { + // Check if we have existing metadata for this file to preserve URL and other data + val existing = getPluginsLocal().firstOrNull { it.filePath == destinationFile.absolutePath } + val data = existing ?: PluginData(name, null, false, destinationFile.absolutePath, PLUGIN_VERSION_NOT_SET) + + if (loadPlugin(context, destinationFile, data)) { + // Successfully loaded, add to our batch list + val updatedData = getPluginsLocal().firstOrNull { it.filePath == destinationFile.absolutePath } ?: data + localPluginsToSet.add(updatedData) + Log.d(TAG, "Successfully registered plugin: ${updatedData.internalName}") + } + } } catch (t: Throwable) { - Log.e(TAG, "Failed to copy the file") + Log.e(TAG, "Failed to copy/load the file ${file.name}") logError(t) } } + + // Finalize the local plugin list with a single batched write to avoid race conditions + if (localPluginsToSet.isNotEmpty()) { + Log.d(TAG, "Batch updating PLUGINS_KEY_LOCAL with ${localPluginsToSet.size} plugins") + lock.withLock { + setKey(PLUGINS_KEY_LOCAL, localPluginsToSet.toTypedArray(), commit = true) + } + } loadedLocalPlugins = true afterPluginsLoadedEvent.invoke(forceReload) @@ -633,8 +701,11 @@ object PluginManager { val pluginInstance: BasePlugin = pluginClass.getDeclaredConstructor().newInstance() as BasePlugin - // Sets with the proper version - setPluginData(data.copy(version = version)) + // Sets with the proper version ONLY if it has changed to avoid redundant DataStore writes + if (data.version != version) { + Log.d(TAG, "Updating plugin version for ${data.internalName}: ${data.version} -> $version") + setPluginData(data.copy(version = version)) + } if (plugins.containsKey(filePath)) { Log.i(TAG, "Plugin with name $name already exists") @@ -766,9 +837,10 @@ object PluginManager { val data = PluginData( internalName, pluginUrl, - true, + true, // Online plugin from repo, stored in PLUGINS_KEY for loading on restart newFile.absolutePath, - PLUGIN_VERSION_NOT_SET + PLUGIN_VERSION_NOT_SET, + System.currentTimeMillis() ) return if (loadPlugin) { @@ -795,7 +867,10 @@ object PluginManager { return try { if (File(file.absolutePath).delete()) { unloadPlugin(file.absolutePath) - list.forEach { deletePluginData(it) } + list.forEach { + deletePluginData(it) + FirestoreSyncManager.notifyPluginDeleted(it.internalName) + } return true } false @@ -839,7 +914,7 @@ object PluginManager { val updatedPlugins = mutableListOf() - allPlugins.amap { pluginData -> + allPlugins.forEach { pluginData -> if (pluginData.isDisabled) { Log.e( "PluginManager", diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt index 45ed65611e7..1d28b6bee23 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -16,6 +16,7 @@ import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.utils.FirestoreSyncManager import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.BufferedInputStream @@ -169,7 +170,8 @@ object RepositoryManager { repoLock.withLock { val currentRepos = getRepositories() // No duplicates - setKey(REPOSITORIES_KEY, (currentRepos + repository).distinctBy { it.url }) + val newRepos = (currentRepos + repository).distinctBy { it.url }.toTypedArray() + setKey(REPOSITORIES_KEY, newRepos) } } @@ -182,7 +184,7 @@ object RepositoryManager { repoLock.withLock { val currentRepos = getKey>(REPOSITORIES_KEY) ?: emptyArray() // No duplicates - val newRepos = currentRepos.filter { it.url != repository.url } + val newRepos = currentRepos.filter { it.url != repository.url }.toTypedArray() setKey(REPOSITORIES_KEY, newRepos) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt index 0d95de086be..7ee00653f73 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -12,6 +12,7 @@ import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi import com.lagradost.cloudstream3.syncproviders.providers.SimklApi import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi +import com.lagradost.cloudstream3.syncproviders.providers.FirebaseApi import com.lagradost.cloudstream3.utils.DataStoreHelper import java.util.concurrent.TimeUnit @@ -28,6 +29,7 @@ abstract class AccountManager { val addic7ed = Addic7ed() val subDlApi = SubDlApi() val subSourceApi = SubSourceApi() + val firebaseApi = FirebaseApi() var cachedAccounts: MutableMap> var cachedAccountIds: MutableMap @@ -67,7 +69,8 @@ abstract class AccountManager { SyncRepo(localListApi), SubtitleRepo(openSubtitlesApi), SubtitleRepo(addic7ed), - SubtitleRepo(subDlApi) + SubtitleRepo(subDlApi), + FirebaseRepo(firebaseApi) ) fun updateAccountIds() { @@ -112,6 +115,31 @@ abstract class AccountManager { LoadResponse.kitsuIdPrefix = kitsuApi.idPrefix LoadResponse.aniListIdPrefix = aniListApi.idPrefix LoadResponse.simklIdPrefix = simklApi.idPrefix + detectFirebaseAccount() + } + + fun detectFirebaseAccount() { + try { + // If FirebaseAuth has a user but our account manager doesn't, add it. + // We use the default app instance if it exists. + val firebaseUser = com.lagradost.cloudstream3.utils.FirestoreSyncManager.getFirebaseAuth().currentUser ?: return + + val repo = FirebaseRepo(firebaseApi) + if (repo.accounts.none { it.user.id == firebaseUser.uid.hashCode() }) { + val token = AuthToken(accessToken = firebaseUser.uid, payload = firebaseUser.email) + val authUser = AuthUser(firebaseUser.email, firebaseUser.uid.hashCode(), null) + + val currentAccounts = repo.accounts.toMutableList() + currentAccounts.add(AuthData(authUser, token)) + + updateAccounts(repo.idPrefix, currentAccounts.toTypedArray()) + if (repo.accountId == NONE_ID) { + updateAccountsId(repo.idPrefix, authUser.id) + } + } + } catch (t: Throwable) { + // Ignore errors during detection + } } val subtitleProviders = arrayOf( @@ -159,4 +187,12 @@ abstract class AccountManager { return "${if (days != 0L) "$days" + "d " else ""}${if (hours != 0L) "$hours" + "h " else ""}${minutes}m" } } + + class FirebaseRepo(api: AuthAPI) : AuthRepo(api) { + override fun onAccountChanged(id: Int) { + val account = accounts.firstOrNull { it.user.id == id } + val uid = account?.token?.accessToken + com.lagradost.cloudstream3.utils.FirestoreSyncManager.switchAccount(uid ?: "") + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt index 4ae629ab944..3c1b5526663 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt @@ -115,8 +115,11 @@ abstract class AuthRepo(open val api: AuthAPI) { } set(value) { AccountManager.updateAccountsId(idPrefix, value) + onAccountChanged(value) } + open fun onAccountChanged(id: Int) {} + @Throws suspend fun pinRequest() = api.pinRequest() @@ -146,7 +149,8 @@ abstract class AuthRepo(open val api: AuthAPI) { @Throws suspend fun login(form: AuthLoginResponse): Boolean { - return setupLogin(api.login(form) ?: return false) + val token = api.login(form) ?: throw ErrorLoadingException(txt(R.string.authenticated_user_fail, api.name).toString()) + return setupLogin(token) } @Throws diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt index 7a46b411376..dd57ab7a730 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt @@ -25,7 +25,7 @@ import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject +import com.lagradost.cloudstream3.utils.toKotlinObject import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear import com.lagradost.cloudstream3.utils.txt import java.net.URLEncoder @@ -1137,4 +1137,4 @@ class AniListApi : SyncAPI() { data class GetSearchRoot( @JsonProperty("data") val data: GetSearchPage?, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseApi.kt new file mode 100644 index 00000000000..5dbac3c3414 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseApi.kt @@ -0,0 +1,98 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import com.google.firebase.auth.FirebaseAuth +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.utils.FirestoreSyncManager +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class FirebaseApi : AuthAPI() { + override val name = "Firebase" + override val idPrefix = "firebase" + override val icon = R.drawable.ic_baseline_sync_24 + override val requiresLogin = true + override val createAccountUrl = null // We handle registration in-app via login fallback + + override val hasInApp = true + override val inAppLoginRequirement = AuthLoginRequirement( + email = true, + password = true + ) + + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val email = form.email ?: return null + val password = form.password ?: return null + + return suspendCancellableCoroutine { cont -> + val auth = FirestoreSyncManager.getFirebaseAuth() + + // Try Login first + auth.signInWithEmailAndPassword(email, password) + .addOnSuccessListener { result -> + val user = result.user + if (user != null) { + cont.resume( + AuthToken( + accessToken = user.uid, + refreshToken = null, // Managed by Firebase + payload = user.email + ) + ) + } else { + cont.resume(null) + } + } + .addOnFailureListener { loginErr -> + // Fallback to Registration if login fails (simple "Login or Register" flow) + // This matches the previous logic which was convenient for users + auth.createUserWithEmailAndPassword(email, password) + .addOnSuccessListener { result -> + val user = result.user + if (user != null) { + cont.resume( + AuthToken( + accessToken = user.uid, + refreshToken = null, + payload = user.email + ) + ) + } else { + cont.resume(null) + } + } + .addOnFailureListener { regErr -> + // If both fail, throw the login error (usually more relevant if user exists) + // or a combined error. + cont.resumeWithException(Exception("Login: ${loginErr.message}\nRegister: ${regErr.message}")) + } + } + } + } + + override suspend fun user(token: AuthToken?): AuthUser? { + if (token == null) return null + // We can trust the token payload (email) or get fresh from FirebaseAuth + val user = FirestoreSyncManager.getFirebaseAuth().currentUser + return if (user != null && user.uid == token.accessToken) { + AuthUser( + name = user.email, + id = user.uid.hashCode(), // Int ID required by AuthUser, hash is imperfect but standard here + profilePicture = null + ) + } else { + null + } + } + + override suspend fun invalidateToken(token: AuthToken): Nothing { + FirestoreSyncManager.getFirebaseAuth().signOut() + throw NotImplementedError("Firebase tokens cannot be manually invalidated") + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt index ba0195be6b8..58718db76b9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt @@ -19,7 +19,7 @@ import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject +import com.lagradost.cloudstream3.utils.toKotlinObject import com.lagradost.cloudstream3.utils.txt import java.text.SimpleDateFormat import java.time.Instant diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt index ed273a3cef2..9d77b6cbca6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt @@ -41,7 +41,7 @@ import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject +import com.lagradost.cloudstream3.utils.toKotlinObject import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities @@ -448,4 +448,4 @@ class ControllerActivity : ExpandedControllerActivity() { SkipNextEpisodeController(skipOpButton) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt index 78ad2a6bfcb..908bb9a364c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt @@ -71,7 +71,7 @@ class GrdLayoutManager(val context: Context, spanCount: Int) : val orientation = this.orientation // fixes arabic by inverting left and right layout focus - val correctDirection = if (this.isLayoutRTL) { + val correctDirection = if (this.layoutDirection == View.LAYOUT_DIRECTION_RTL) { when (direction) { View.FOCUS_RIGHT -> View.FOCUS_LEFT View.FOCUS_LEFT -> View.FOCUS_RIGHT @@ -169,8 +169,8 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att */ class MaxRecyclerView(ctx: Context, attrs: AttributeSet) : RecyclerView(ctx, attrs) { private var biggestObserved: Int = 0 - private val orientation = LayoutManager.getProperties(context, attrs, 0, 0).orientation - private val isHorizontal = orientation == HORIZONTAL + private val orientation = RecyclerView.LayoutManager.getProperties(context, attrs, 0, 0).orientation + private val isHorizontal = orientation == RecyclerView.HORIZONTAL private fun View.updateMaxSize() { if (isHorizontal) { this.minimumHeight = biggestObserved diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt index d0740f66a81..b6cdbfca70c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt @@ -21,13 +21,6 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.VideoDownloadHelper -const val DOWNLOAD_ACTION_PLAY_FILE = 0 -const val DOWNLOAD_ACTION_DELETE_FILE = 1 -const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2 -const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3 -const val DOWNLOAD_ACTION_DOWNLOAD = 4 -const val DOWNLOAD_ACTION_LONG_CLICK = 5 - const val DOWNLOAD_ACTION_GO_TO_CHILD = 0 const val DOWNLOAD_ACTION_LOAD_RESULT = 1 @@ -55,11 +48,6 @@ sealed class VisualDownloadCached { ) : VisualDownloadCached() } -data class DownloadClickEvent( - val action: Int, - val data: VideoDownloadHelper.DownloadEpisodeCached -) - data class DownloadHeaderClickEvent( val action: Int, val data: VideoDownloadHelper.DownloadHeaderCached diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadClickEvent.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadClickEvent.kt new file mode 100644 index 00000000000..0c06eafb899 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadClickEvent.kt @@ -0,0 +1,12 @@ +package com.lagradost.cloudstream3.ui.download + +import com.lagradost.cloudstream3.utils.VideoDownloadHelper + +const val DOWNLOAD_ACTION_PLAY_FILE = 0 +const val DOWNLOAD_ACTION_DELETE_FILE = 1 +const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 2 +const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 3 +const val DOWNLOAD_ACTION_LONG_CLICK = 4 +const val DOWNLOAD_ACTION_DOWNLOAD = 5 + +data class DownloadClickEvent(val action: Int, val data: VideoDownloadHelper.DownloadEpisodeCached) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt index 3bd424640dd..11839af42e7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt @@ -40,10 +40,11 @@ import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback +import com.lagradost.cloudstream3.utils.getFolderName import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE -import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard @@ -366,4 +367,4 @@ class DownloadFragment : BaseFragment( val selectedVideoUri = result.data?.data ?: return@registerForActivityResult playUri(activity ?: return@registerForActivityResult, selectedVideoUri) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt index ee69390ff2b..36577f65c4a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.ui.download import android.content.Context +import com.lagradost.cloudstream3.utils.getFolderName import android.content.DialogInterface import android.os.Environment import android.os.StatFs @@ -18,9 +19,8 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.ConsistentLiveData import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE -import com.lagradost.cloudstream3.utils.DataStore.getFolderName -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.getKeys +import com.lagradost.cloudstream3.utils.getKey +import com.lagradost.cloudstream3.utils.getKeys import com.lagradost.cloudstream3.utils.ResourceLiveData import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings @@ -457,4 +457,4 @@ class DownloadViewModel : ViewModel() { val names: List, val parentName: String? ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index 6df5bbbef1d..1048cc2efa4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -420,6 +420,10 @@ class HomeViewModel : ViewModel() { reloadStored() } + private fun onSyncUpdated(unused: Boolean) { + loadResumeWatching() + } + private fun afterPluginsLoaded(forceReload: Boolean) { loadAndCancel(DataStoreHelper.currentHomePage, forceReload) } @@ -440,6 +444,7 @@ class HomeViewModel : ViewModel() { init { MainActivity.bookmarksUpdatedEvent += ::bookmarksUpdated + MainActivity.syncUpdatedEvent += ::onSyncUpdated MainActivity.afterPluginsLoadedEvent += ::afterPluginsLoaded MainActivity.mainPluginsLoadedEvent += ::afterMainPluginsLoaded MainActivity.reloadHomeEvent += ::reloadHome @@ -448,6 +453,7 @@ class HomeViewModel : ViewModel() { override fun onCleared() { MainActivity.bookmarksUpdatedEvent -= ::bookmarksUpdated + MainActivity.syncUpdatedEvent -= ::onSyncUpdated MainActivity.afterPluginsLoadedEvent -= ::afterPluginsLoaded MainActivity.mainPluginsLoadedEvent -= ::afterMainPluginsLoaded MainActivity.reloadHomeEvent -= ::reloadHome diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index fdcbb044cff..10b52787dd0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -386,10 +386,10 @@ class CS3IPlayer : IPlayer { ?: return } - override fun setPreferredAudioTrack(trackLanguage: String?, id: String?, formatIndex: Int?) { + override fun setPreferredAudioTrack(trackLanguage: String?, id: String?, trackIndex: Int?) { preferredAudioTrackLanguage = trackLanguage id?.let { trackId -> - val trackFormatIndex = formatIndex ?: 0 + val trackFormatIndex = trackIndex ?: 0 exoPlayer?.currentTracks?.groups ?.filter { it.type == TRACK_TYPE_AUDIO } ?.find { group -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt index 0999e9c158a..7240a90c724 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt @@ -20,6 +20,7 @@ */ package com.lagradost.cloudstream3.ui.player +import android.os.Build import android.text.Html import android.text.Spanned import android.text.TextUtils @@ -115,7 +116,12 @@ class CustomSubripParser : SubtitleParser { currentLine = parsableByteArray.readLine(charset) } - val text = Html.fromHtml(textBuilder.toString()) + val text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Html.fromHtml(textBuilder.toString(), Html.FROM_HTML_MODE_LEGACY) + } else { + @Suppress("DEPRECATION") + Html.fromHtml(textBuilder.toString()) + } var alignmentTag: String? = null for (i in tags.indices) { @@ -260,9 +266,9 @@ class CustomSubripParser : SubtitleParser { val hours = matcher.group(groupOffset + 1) var timestampMs = if (hours != null) hours.toLong() * 60 * 60 * 1000 else 0 timestampMs += - Assertions.checkNotNull(matcher.group(groupOffset + 2)) + Assertions.checkNotNull(matcher.group(groupOffset + 2)) .toLong() * 60 * 1000 - timestampMs += Assertions.checkNotNull(matcher.group(groupOffset + 3)) + timestampMs += Assertions.checkNotNull(matcher.group(groupOffset + 3)) .toLong() * 1000 val millis = matcher.group(groupOffset + 4) if (millis != null) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 4b4d0b5fadd..84b857b4978 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -1,7 +1,12 @@ package com.lagradost.cloudstream3.ui.result import android.app.Activity +import com.lagradost.cloudstream3.utils.getFolderName +import com.lagradost.cloudstream3.utils.mapper +import com.lagradost.cloudstream3.utils.editor +import com.lagradost.cloudstream3.utils.setKeyRaw import android.content.* +import android.content.SharedPreferences import android.util.Log import android.widget.Toast import androidx.annotation.MainThread @@ -57,9 +62,9 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DataStore.editor -import com.lagradost.cloudstream3.utils.DataStore.getFolderName -import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.setKey +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllBookmarkedData @@ -758,7 +763,7 @@ class ResultViewModel2 : ViewModel() { ) setKey( - DataStore.getFolderName( + getFolderName( DOWNLOAD_EPISODE_CACHE, parentId.toString() ), // 3 deep folder for faster acess @@ -1463,11 +1468,11 @@ class ResultViewModel2 : ViewModel() { } private fun markEpisodes( - editor: Editor, + editor: SharedPreferences.Editor, episodeIds: Array, watchState: VideoWatchState ) { - val watchStateString = DataStore.mapper.writeValueAsString(watchState) + val watchStateString = mapper.writeValueAsString(watchState) episodeIds.forEach { if (getVideoWatchState(it.toInt()) != watchState) { editor.setKeyRaw( @@ -1725,7 +1730,7 @@ class ResultViewModel2 : ViewModel() { } ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE -> ioSafe { - val editor = context?.let { it1 -> editor(it1, false) } + val editor = context?.let { com.lagradost.cloudstream3.utils.editor(it, false) } if (editor != null) { val (clickSeason, clickEpisode) = click.data.let { @@ -2844,4 +2849,4 @@ class ResultViewModel2 : ViewModel() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt index be8b4180c2b..39ba433f3d4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt @@ -52,6 +52,10 @@ class AccountAdapter( root.setOnClickListener { clickCallback.invoke(AccountClickCallback(0, root, item)) } + + accountDelete.setOnClickListener { + clickCallback.invoke(AccountClickCallback(1, it, item)) + } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 7c24cd7a9a9..d60447390c5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -27,6 +27,7 @@ import com.lagradost.cloudstream3.databinding.AccountSwitchBinding import com.lagradost.cloudstream3.databinding.AddAccountInputBinding import com.lagradost.cloudstream3.databinding.DeviceAuthBinding import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi @@ -63,6 +64,7 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogTe import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard +import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import qrcode.QRCode @@ -117,7 +119,7 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { } } - private fun showAccountSwitch(activity: FragmentActivity, api: AuthRepo) { + internal fun showAccountSwitch(activity: FragmentActivity, api: AuthRepo) { val accounts = api.accounts val binding: AccountSwitchBinding = AccountSwitchBinding.inflate(activity.layoutInflater, null, false) @@ -132,14 +134,30 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { dialog?.dismissSafe(activity) } - binding.accountNone.setOnClickListener { + binding.accountNone.isVisible = false + /*binding.accountNone.setOnClickListener { api.accountId = -1 dialog?.dismissSafe(activity) - } - - val adapter = AccountAdapter { - dialog?.dismissSafe(activity) - api.accountId = it.card.user.id + }*/ + + val adapter = AccountAdapter { callback -> + if (callback.action == 1) { // Delete + ioSafe { + api.logout(callback.card.user) + activity.runOnUiThread { + val newAccounts = api.accounts + if (newAccounts.isEmpty()) { + dialog?.dismissSafe(activity) + } else { + // Refresh list + (dialog.findViewById(R.id.account_list)?.adapter as? AccountAdapter)?.submitList(newAccounts.toList()) + } + } + } + } else { + dialog?.dismissSafe(activity) + api.accountId = callback.card.user.id + } }.apply { submitList(accounts.toList()) } @@ -396,7 +414,7 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { throw NotImplementedError("The api ${api.name} has no login") } } catch (t: Throwable) { - showToast(txt(R.string.authenticated_user_fail, api.name)) + showToast(t.message ?: txt(R.string.authenticated_user_fail, api.name).toString()) logError(t) } } @@ -468,6 +486,7 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { R.string.simkl_key to SyncRepo(simklApi), R.string.opensubtitles_key to SubtitleRepo(openSubtitlesApi), R.string.subdl_key to SubtitleRepo(subDlApi), + R.string.firebase_key to AccountManager.FirebaseRepo(AccountManager.firebaseApi), ) for ((key, api) in syncApis) { @@ -478,7 +497,11 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { val info = api.authUser() val index = api.accounts.indexOfFirst { account -> account.user.id == info?.id } if (api.accounts.isNotEmpty()) { - showLoginInfo(activity, api, info, index) + if (api is AccountManager.FirebaseRepo && api.accounts.size > 1) { + showAccountSwitch(activity, api) + } else { + showLoginInfo(activity, api, info, index) + } } else { addAccount(activity, api) } @@ -486,5 +509,10 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { } } } + + getPref(R.string.firebase_sync_key)?.setOnPreferenceClickListener { + activity?.navigate(R.id.global_to_navigation_sync_settings) + true + } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt index 097eb2c600b..fcb44ce7a37 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt @@ -224,6 +224,7 @@ class SettingsFragment : BaseFragment( settingsGeneral to R.id.action_navigation_global_to_navigation_settings_general, settingsPlayer to R.id.action_navigation_global_to_navigation_settings_player, settingsCredits to R.id.action_navigation_global_to_navigation_settings_account, + // settingsSync removed settingsUi to R.id.action_navigation_global_to_navigation_settings_ui, settingsProviders to R.id.action_navigation_global_to_navigation_settings_providers, settingsUpdates to R.id.action_navigation_global_to_navigation_settings_updates, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt new file mode 100644 index 00000000000..fb14cd8a50e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt @@ -0,0 +1,401 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.content.res.ColorStateList +import android.graphics.Color +import android.os.Bundle +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentSyncSettingsBinding +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.utils.getKey +import com.lagradost.cloudstream3.utils.setKey +import com.lagradost.cloudstream3.utils.FirestoreSyncManager +import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import androidx.fragment.app.FragmentActivity +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.databinding.ItemSyncPluginBinding +import com.lagradost.cloudstream3.plugins.PluginData +import com.lagradost.cloudstream3.ui.settings.SettingsFragment +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.MainActivity.Companion.syncUpdatedEvent + +class SyncSettingsFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSyncSettingsBinding::inflate) +) { + data class SyncToggle( + val key: String, + val title: String, + val desc: String + ) + + override fun fixLayout(view: View) { + // Fix for TV: ensure items are focusable + } + + override fun onResume() { + super.onResume() + updateUI() + } + + override fun onBindingCreated(binding: FragmentSyncSettingsBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + binding.syncToolbar.setNavigationOnClickListener { + activity?.onBackPressedDispatcher?.onBackPressed() + } + + setupDatabaseConfigInputs(binding) + setupGranularToggles(binding) + setupAuthActions(binding) + setupPluginActions(binding) + + binding.syncConnectBtn.setOnClickListener { + connect(binding) + } + + binding.syncNowBtn.setOnClickListener { + val ctx = context ?: return@setOnClickListener + showToast(txt(R.string.sync_syncing)) + FirestoreSyncManager.syncNow(ctx) + view?.postDelayed({ updateUI() }, 1000) + } + + /*binding.syncCopyLogsBtn.setOnClickListener { + val logs = FirestoreSyncManager.getLogs() + if (logs.isBlank()) { + showToast(txt(R.string.sync_no_logs)) + } else { + clipboardHelper(txt(R.string.sync_logs_label), logs) + showToast(txt(R.string.sync_logs_copied)) + } + }*/ + + // Toggle Database Config Visibility + binding.syncConfigHeader.setOnClickListener { + val isVisible = binding.syncConfigContainer.isVisible + binding.syncConfigContainer.isVisible = !isVisible + // Rotation animation + val arrow = binding.syncConfigHeader.getChildAt(1) + arrow.animate().rotation(if (isVisible) 0f else 180f).setDuration(200).start() + } + + // Auto-expand if not enabled/connected + val ctx = context ?: return + val isEnabled = FirestoreSyncManager.isEnabled(ctx) + binding.syncConfigContainer.isVisible = !isEnabled + if (!isEnabled) { + binding.syncConfigHeader.getChildAt(1).rotation = 180f + } + + updateUI() + + // Refresh UI when sync data changes (like new plugins detected) + MainActivity.syncUpdatedEvent += { success: Boolean -> + if (success) main { updateUI() } + } + } + + private fun setupDatabaseConfigInputs(binding: FragmentSyncSettingsBinding) { + val context = context ?: return + binding.apply { + // Fix: Use getKey to ensure we get the clean string value (handling JSON quotes if any) + syncApiKey.setText(context.getKey(FirestoreSyncManager.FIREBASE_API_KEY) ?: "") + syncProjectId.setText(context.getKey(FirestoreSyncManager.FIREBASE_PROJECT_ID) ?: "") + syncAppId.setText(context.getKey(FirestoreSyncManager.FIREBASE_APP_ID) ?: "") + + val checkBtn = { + syncConnectBtn.isEnabled = syncApiKey.text?.isNotBlank() == true && + syncProjectId.text?.isNotBlank() == true && + syncAppId.text?.isNotBlank() == true + } + + syncApiKey.doAfterTextChanged { checkBtn() } + syncProjectId.doAfterTextChanged { checkBtn() } + syncAppId.doAfterTextChanged { checkBtn() } + checkBtn() + } + } + + private fun setupAuthActions(binding: FragmentSyncSettingsBinding) { + // Direct Account Management for Firebase + binding.syncAccountActionBtn.setOnClickListener { + val activity = activity as? FragmentActivity ?: return@setOnClickListener + val api = AccountManager.FirebaseRepo(AccountManager.firebaseApi) + val info = api.authUser() + val index = api.accounts.indexOfFirst { account -> account.user.id == info?.id } + + if (api.accounts.isNotEmpty()) { + if (api.accounts.size > 1) { + SettingsAccount.showAccountSwitch(activity, api) + } else { + SettingsAccount.showLoginInfo(activity, api, info, index) + } + } else { + SettingsAccount.addAccount(activity, api) + } + } + } + + private fun setupPluginActions(binding: FragmentSyncSettingsBinding) { + binding.syncInstallPluginsBtn.setOnClickListener { + showToast(txt(R.string.sync_installing_plugins)) + ioSafe { + val act = activity ?: return@ioSafe + FirestoreSyncManager.installAllPending(act) + main { updateUI() } + } + } + + binding.syncIgnorePluginsBtn.setOnClickListener { + val ctx = context ?: return@setOnClickListener + // Updated to use the new robust ignore logic + FirestoreSyncManager.ignoreAllPendingPlugins(ctx) + updateUI() + showToast(txt(R.string.sync_pending_cleared)) + } + } + + private fun setupGranularToggles(binding: FragmentSyncSettingsBinding) { + binding.apply { + setupToggleCategory(syncAppearanceRecycler, listOf( + SyncToggle(FirestoreSyncManager.SYNC_SETTING_APPEARANCE, getString(R.string.sync_appearance_title), getString(R.string.sync_appearance_desc)) + )) + setupToggleCategory(syncPlayerRecycler, listOf( + SyncToggle(FirestoreSyncManager.SYNC_SETTING_PLAYER, getString(R.string.sync_player_title), getString(R.string.sync_player_desc)) + )) + setupToggleCategory(syncDownloadsRecycler, listOf( + SyncToggle(FirestoreSyncManager.SYNC_SETTING_DOWNLOADS, getString(R.string.sync_downloads_title), getString(R.string.sync_downloads_desc)) + )) + setupToggleCategory(syncGeneralRecycler, listOf( + SyncToggle(FirestoreSyncManager.SYNC_SETTING_GENERAL, getString(R.string.sync_general_title), getString(R.string.sync_general_desc)) + )) + + setupToggleCategory(syncAccountsRecycler, listOf( + SyncToggle(FirestoreSyncManager.SYNC_SETTING_ACCOUNTS, getString(R.string.sync_user_profiles_title), getString(R.string.sync_user_profiles_desc)) + )) + setupToggleCategory(syncBookmarksRecycler, listOf( + SyncToggle(FirestoreSyncManager.SYNC_SETTING_BOOKMARKS, getString(R.string.sync_bookmarks_title), getString(R.string.sync_bookmarks_desc)) + )) + setupToggleCategory(syncResumeWatchingRecycler, listOf( + SyncToggle(FirestoreSyncManager.SYNC_SETTING_RESUME_WATCHING, getString(R.string.sync_watch_progress_title), getString(R.string.sync_watch_progress_desc)) + )) + + setupToggleCategory(syncRepositoriesRecycler, listOf( + SyncToggle(FirestoreSyncManager.SYNC_SETTING_REPOSITORIES, getString(R.string.sync_repositories_title), getString(R.string.sync_repositories_desc)) + )) + setupToggleCategory(syncPluginsRecycler, listOf( + SyncToggle(FirestoreSyncManager.SYNC_SETTING_PLUGINS, getString(R.string.sync_plugins_title), getString(R.string.sync_plugins_desc)) + )) + + setupToggleCategory(syncHomepageRecycler, listOf( + SyncToggle(FirestoreSyncManager.SYNC_SETTING_HOMEPAGE_API, getString(R.string.sync_home_provider_title), getString(R.string.sync_home_provider_desc)) + )) + setupToggleCategory(syncPinnedRecycler, listOf( + SyncToggle(FirestoreSyncManager.SYNC_SETTING_PINNED_PROVIDERS, getString(R.string.sync_pinned_providers_title), getString(R.string.sync_pinned_providers_desc)) + )) + } + } + + private fun setupToggleCategory(recycler: RecyclerView, toggles: List) { + recycler.adapter = ToggleAdapter(toggles) + recycler.isNestedScrollingEnabled = false + } + + + + private fun connect(binding: FragmentSyncSettingsBinding) { + val config = FirestoreSyncManager.SyncConfig( + apiKey = binding.syncApiKey.text?.toString() ?: "", + projectId = binding.syncProjectId.text?.toString() ?: "", + appId = binding.syncAppId.text?.toString() ?: "" + ) + + val ctx = context ?: return + FirestoreSyncManager.initialize(ctx, config) + showToast(txt(R.string.sync_connecting)) + view?.postDelayed({ updateUI() }, 1500) + } + + private fun updateUI() { + val binding = binding ?: return + val context = context ?: return + + val enabled = FirestoreSyncManager.isEnabled(context) + val isOnline = FirestoreSyncManager.isOnline() + val isLogged = FirestoreSyncManager.isLogged() + + updateConnectionStatus(binding, context, enabled, isOnline, isLogged) + updateAuthState(binding, context, isLogged) + updatePendingPlugins(binding, context, enabled) + } + + private fun updateConnectionStatus( + binding: FragmentSyncSettingsBinding, + context: android.content.Context, + enabled: Boolean, + isOnline: Boolean, + isLogged: Boolean + ) { + binding.syncStatusCard.isVisible = enabled + binding.syncAccountCard.isVisible = enabled + + if (!enabled) { + binding.syncConnectBtn.text = getString(R.string.sync_connect_database) + return + } + + if (isLogged) { + binding.syncStatusText.text = getString(R.string.sync_status_connected) + binding.syncStatusText.setTextColor(context.getColor(R.color.sync_status_connected)) + } else if (isOnline) { + binding.syncStatusText.text = getString(R.string.sync_status_login_needed) + binding.syncStatusText.setTextColor(context.getColor(R.color.sync_status_login_needed)) + } else { + val error = FirestoreSyncManager.lastInitError + binding.syncStatusText.text = if (error != null) getString(R.string.sync_status_error_prefix, error) else getString(R.string.sync_status_disconnected) + binding.syncStatusText.setTextColor(context.getColor(R.color.sync_status_error)) + } + + val lastSync = FirestoreSyncManager.getLastSyncTime(context) + binding.syncLastTime.text = lastSync?.let { it: Long -> + SimpleDateFormat("MMM dd, HH:mm:ss", Locale.getDefault()).format(Date(it)) + } ?: getString(R.string.sync_never) + } + + private fun updateAuthState( + binding: FragmentSyncSettingsBinding, + context: android.content.Context, + isLogged: Boolean + ) { + if (isLogged) { + val email = FirestoreSyncManager.getUserEmail() ?: getString(R.string.sync_unknown_user_label) + binding.syncAccountStatus.text = getString(R.string.sync_signed_in_as, email) + binding.syncAccountStatus.setTextColor(context.getColor(R.color.sync_status_connected)) + binding.syncAccountActionBtn.text = getString(R.string.sync_manage_accounts) + } else { + binding.syncAccountStatus.text = getString(R.string.sync_not_logged_in) + binding.syncAccountStatus.setTextColor(context.getColor(R.color.sync_status_error)) + binding.syncAccountActionBtn.text = getString(R.string.sync_login_via_accounts) + } + + val contentVisible = isLogged + binding.syncAppSettingsCard.isVisible = contentVisible + binding.syncLibraryCard.isVisible = contentVisible + binding.syncExtensionsCard.isVisible = contentVisible + binding.syncInterfaceCard.isVisible = contentVisible + } + + private fun updatePendingPlugins( + binding: FragmentSyncSettingsBinding, + context: android.content.Context, + enabled: Boolean + ) { + val pendingPlugins = FirestoreSyncManager.getPendingPlugins(context) + + if (pendingPlugins.isNotEmpty() && enabled) { + binding.syncPendingPluginsCard.isVisible = true + binding.syncPendingTitle.text = getString(R.string.sync_new_plugins_detected, pendingPlugins.size) + + val adapter = PluginAdapter(pendingPlugins) { plugin: PluginData, action: String -> + when (action) { + "INSTALL" -> { + ioSafe { + val activity = activity ?: return@ioSafe + val success = FirestoreSyncManager.installPendingPlugin(activity, plugin) + main { if (success) updateUI() } + } + } + "IGNORE" -> { + FirestoreSyncManager.ignorePendingPlugin(context, plugin) + updateUI() + } + } + } + binding.syncPendingPluginsRecycler.adapter = adapter + binding.syncPendingPluginsRecycler.isVisible = true + } else { + binding.syncPendingPluginsCard.isVisible = false + } + } + + inner class PluginAdapter( + private val items: List, + private val callback: (PluginData, String) -> Unit + ) : RecyclerView.Adapter() { + + inner class PluginViewHolder(val binding: ItemSyncPluginBinding) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PluginViewHolder { + val binding = ItemSyncPluginBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return PluginViewHolder(binding) + } + + override fun onBindViewHolder(holder: PluginViewHolder, position: Int) { + val item = items[position] + holder.binding.itemPluginName.text = item.internalName + holder.binding.itemPluginInstall.setOnClickListener { callback(item, "INSTALL") } + holder.binding.itemPluginIgnore.setOnClickListener { callback(item, "IGNORE") } + + // TV Navigation support: Don't let root intercept focus from buttons + holder.binding.root.isFocusable = false + holder.binding.root.isClickable = false + } + + override fun getItemCount() = items.size + } + + inner class ToggleAdapter( + private val items: List + ) : RecyclerView.Adapter() { + + inner class ToggleViewHolder(val binding: com.lagradost.cloudstream3.databinding.SyncItemRowBinding) : + RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ToggleViewHolder { + val binding = com.lagradost.cloudstream3.databinding.SyncItemRowBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + return ToggleViewHolder(binding) + } + + override fun onBindViewHolder(holder: ToggleViewHolder, position: Int) { + val item = items[position] + holder.binding.syncItemTitle.text = item.title + holder.binding.syncItemDesc.text = item.desc + + val ctx = holder.binding.root.context + val current = ctx.getKey(item.key, true) ?: true + + holder.binding.syncItemSwitch.setOnCheckedChangeListener(null) + holder.binding.syncItemSwitch.isChecked = current + holder.binding.syncItemSwitch.setOnCheckedChangeListener { _, isChecked -> + ctx.setKey(item.key, isChecked) + } + + // TV Navigation support + holder.binding.root.isFocusable = true + holder.binding.root.isClickable = true + holder.binding.root.setOnClickListener { + holder.binding.syncItemSwitch.toggle() + } + } + + override fun getItemCount() = items.size + } +} 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 e0fd906b49f..da776937914 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 @@ -172,7 +172,11 @@ class PluginsViewModel : ViewModel() { ) val (success, message) = if (file.exists()) { - PluginManager.deletePlugin(file) to R.string.plugin_deleted + val res = PluginManager.deletePlugin(file) + if (res) { + ioSafe { com.lagradost.cloudstream3.utils.FirestoreSyncManager.notifyPluginDeleted(plugin.second.internalName) } + } + res 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 diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt index 8da121daa98..add5b756cc0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt @@ -64,7 +64,7 @@ class SetupFragmentMedia : BaseFragment( } nextBtt.setOnClickListener { - findNavController().navigate(R.id.navigation_setup_media_to_navigation_setup_layout) + findNavController().navigate(R.id.action_navigation_setup_media_to_navigation_setup_sync) } prevBtt.setOnClickListener { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentSync.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentSync.kt new file mode 100644 index 00000000000..ae57a56ce39 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentSync.kt @@ -0,0 +1,53 @@ +package com.lagradost.cloudstream3.ui.setup + +import android.view.View +import androidx.core.view.isVisible +import androidx.navigation.fragment.findNavController +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentSetupSyncBinding +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.utils.FirestoreSyncManager +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding + +class SetupFragmentSync : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupSyncBinding::inflate) +) { + + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) + } + + override fun onResume() { + super.onResume() + updateUI() + } + + private fun updateUI() { + val binding = binding ?: return + val context = context ?: return + + if (FirestoreSyncManager.isLogged()) { + binding.syncDescriptionText.text = getString(R.string.sync_setup_connected_title) + "\n" + getString(R.string.sync_setup_connected_desc) + // Hide the "Yes, Setup Sync" button since it is already done + binding.syncYesBtt.isVisible = false + // The Next button is already named "Next" in XML + } else { + binding.syncDescriptionText.text = getString(R.string.sync_setup_welcome_desc) + binding.syncYesBtt.isVisible = true + } + } + + override fun onBindingCreated(binding: FragmentSetupSyncBinding) { + // "Yes, Setup Sync" -> Go to Sync Settings + binding.syncYesBtt.setOnClickListener { + findNavController().navigate(R.id.action_navigation_setup_sync_to_navigation_settings_sync) + } + + // "Next" -> Go to Next step (App Layout) + binding.nextBtt.setOnClickListener { + findNavController().navigate(R.id.action_navigation_setup_sync_to_navigation_setup_layout) + } + + updateUI() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt index f9b1cb1fe88..956541ba05f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt @@ -31,7 +31,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt index 5f716cca3f1..70cdab4b6a4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt @@ -44,7 +44,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 96171aa90b9..d1aa9cba2eb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -1,6 +1,9 @@ package com.lagradost.cloudstream3.utils import android.content.Context +import com.lagradost.cloudstream3.utils.editor +import com.lagradost.cloudstream3.utils.setKeyRaw +import com.lagradost.cloudstream3.utils.mapper import android.net.Uri import android.widget.Toast import androidx.activity.result.ActivityResultLauncher @@ -23,9 +26,8 @@ import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_C import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi.Companion.KITSU_CACHED_LIST import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs -import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs -import com.lagradost.cloudstream3.utils.DataStore.mapper +import com.lagradost.cloudstream3.utils.getDefaultSharedPrefs +import com.lagradost.cloudstream3.utils.getSharedPrefs import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.requestRW import com.lagradost.cloudstream3.utils.VideoDownloadManager.StreamData @@ -231,7 +233,7 @@ object BackupUtils { ?: return@ioSafe val restoredValue = - mapper.readValue(input) + mapper.readValue(input, BackupFile::class.java) restore( activity, @@ -280,7 +282,7 @@ object BackupUtils { map: Map?, isEditingAppSettings: Boolean = false ) { - val editor = DataStore.editor(this, isEditingAppSettings) + val editor = com.lagradost.cloudstream3.utils.editor(this, isEditingAppSettings) map?.forEach { if (it.key.isTransferable()) { editor.setKeyRaw(it.key, it.value) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index 20d33c11218..73379d93119 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.utils import android.content.Context import android.content.SharedPreferences +import android.util.Log import androidx.preference.PreferenceManager import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.json.JsonMapper @@ -23,8 +24,6 @@ const val USER_SELECTED_HOMEPAGE_API = "home_api_used" const val USER_PROVIDER_API = "user_custom_sites" const val PREFERENCES_NAME = "rebuild_preference" -// TODO degelgate by value for get & set - class PreferenceDelegate( val key: String, val default: T //, private val klass: KClass ) { @@ -52,161 +51,214 @@ class PreferenceDelegate( } /** When inserting many keys use this function, this is because apply for every key is very expensive on memory */ -data class Editor( - val editor: SharedPreferences.Editor -) { - /** Always remember to call apply after */ - fun setKeyRaw(path: String, value: T) { - @Suppress("UNCHECKED_CAST") - if (isStringSet(value)) { - editor.putStringSet(path, value as Set) - } else { - when (value) { - is Boolean -> editor.putBoolean(path, value) - is Int -> editor.putInt(path, value) - is String -> editor.putString(path, value) - is Float -> editor.putFloat(path, value) - is Long -> editor.putLong(path, value) - } - } - } +fun editor(context: Context, isEditingAppSettings: Boolean = false): SharedPreferences.Editor { + return if (isEditingAppSettings) context.getDefaultSharedPrefs() + .edit() else context.getSharedPrefs().edit() +} - private fun isStringSet(value: Any?): Boolean { - if (value is Set<*>) { - return value.filterIsInstance().size == value.size +fun SharedPreferences.Editor.setKeyRaw(path: String, value: T) { + @Suppress("UNCHECKED_CAST") + if (value is Set<*>) { + putStringSet(path, value as Set) + } else { + when (value) { + is Boolean -> putBoolean(path, value) + is Int -> putInt(path, value) + is String -> putString(path, value) + is Float -> putFloat(path, value) + is Long -> putLong(path, value) } - return false } +} - fun apply() { - editor.apply() - System.gc() - } +val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() + +fun getFolderName(folder: String, path: String): String { + return "${folder}/${path}" } object DataStore { - val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() - - private fun getPreferences(context: Context): SharedPreferences { + fun getPreferences(context: Context): SharedPreferences { return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) } +} - fun Context.getSharedPrefs(): SharedPreferences { - return getPreferences(this) - } +// Top-level extension functions - fun getFolderName(folder: String, path: String): String { - return "${folder}/${path}" - } +fun Context.getSharedPrefs(): SharedPreferences { + return DataStore.getPreferences(this) +} - fun editor(context: Context, isEditingAppSettings: Boolean = false): Editor { - val editor: SharedPreferences.Editor = - if (isEditingAppSettings) context.getDefaultSharedPrefs() - .edit() else context.getSharedPrefs().edit() - return Editor(editor) - } +fun Context.getDefaultSharedPrefs(): SharedPreferences { + return PreferenceManager.getDefaultSharedPreferences(this) +} - fun Context.getDefaultSharedPrefs(): SharedPreferences { - return PreferenceManager.getDefaultSharedPreferences(this) - } +fun Context.getKeys(folder: String): List { + return this.getSharedPrefs().all.keys.filter { it.startsWith(folder) } +} - fun Context.getKeys(folder: String): List { - return this.getSharedPrefs().all.keys.filter { it.startsWith(folder) } - } +fun Context.removeKey(folder: String, path: String) { + removeKey(getFolderName(folder, path)) +} - fun Context.removeKey(folder: String, path: String) { - removeKey(getFolderName(folder, path)) - } +fun Context.containsKey(folder: String, path: String): Boolean { + return containsKey(getFolderName(folder, path)) +} - fun Context.containsKey(folder: String, path: String): Boolean { - return containsKey(getFolderName(folder, path)) - } +fun Context.containsKey(path: String): Boolean { + val prefs = getSharedPrefs() + return prefs.contains(path) +} - fun Context.containsKey(path: String): Boolean { +fun Context.removeKey(path: String) { + try { val prefs = getSharedPrefs() - return prefs.contains(path) - } - - fun Context.removeKey(path: String) { - try { - val prefs = getSharedPrefs() - if (prefs.contains(path)) { - prefs.edit { - remove(path) - } + if (prefs.contains(path)) { + prefs.edit { + remove(path) } - } catch (e: Exception) { - logError(e) } + // Hook for Sync: Delete + FirestoreSyncManager.pushDelete(path) + } catch (e: Exception) { + logError(e) } +} - fun Context.removeKeys(folder: String): Int { - val keys = getKeys("$folder/") - try { - getSharedPrefs().edit { - keys.forEach { value -> - remove(value) - } +fun Context.removeKeys(folder: String): Int { + val keys = getKeys("$folder/") + try { + getSharedPrefs().edit { + keys.forEach { value -> + remove(value) } - return keys.size - } catch (e: Exception) { - logError(e) - return 0 } + // Sync hook for bulk delete? Maybe difficult, ignoring for now or iterate + keys.forEach { FirestoreSyncManager.pushDelete(it) } + return keys.size + } catch (e: Exception) { + logError(e) + return 0 } +} - fun Context.setKey(path: String, value: T) { - try { - getSharedPrefs().edit { - putString(path, mapper.writeValueAsString(value)) - } - } catch (e: Exception) { - logError(e) +fun Context.setKey(path: String, value: T, commit: Boolean = false) { + try { + val json = mapper.writeValueAsString(value) + val current = getSharedPrefs().getString(path, null) + if (current == json) return + + getSharedPrefs().edit(commit = commit) { + putString(path, json) } + // Hook for Sync: Write + FirestoreSyncManager.pushWrite(path, json) + } catch (e: Exception) { + logError(e) } +} - fun Context.getKey(path: String, valueType: Class): T? { - try { - val json: String = getSharedPrefs().getString(path, null) ?: return null - return json.toKotlinObject(valueType) - } catch (e: Exception) { - return null +// Internal local set without sync hook (used by sync manager to avoid loops) +fun Context.setKeyLocal(path: String, value: T, commit: Boolean = false) { + try { + // Handle generic value or raw string + val stringValue = if (value is String) value else mapper.writeValueAsString(value) + getSharedPrefs().edit(commit = commit) { + putString(path, stringValue) } + } catch (e: Exception) { + logError(e) } +} - fun Context.setKey(folder: String, path: String, value: T) { - setKey(getFolderName(folder, path), value) - } +fun Context.setKeyLocal(folder: String, path: String, value: T, commit: Boolean = false) { + setKeyLocal(getFolderName(folder, path), value, commit) +} - inline fun String.toKotlinObject(): T { - return mapper.readValue(this, T::class.java) +fun Context.removeKeyLocal(path: String) { + try { + getSharedPrefs().edit { + remove(path) + } + } catch (e: Exception) { + logError(e) } +} - fun String.toKotlinObject(valueType: Class): T { - return mapper.readValue(this, valueType) +fun Context.getKey(path: String, valueType: Class): T? { + try { + val json: String = getSharedPrefs().getString(path, null) ?: return null + Log.d("DataStore", "getKey(Class) $path raw: '$json'") + return json.toKotlinObject(valueType) + } catch (e: Exception) { + Log.e("DataStore", "getKey(Class) $path error: ${e.message}") + return null } +} + +fun Context.setKey(folder: String, path: String, value: T, commit: Boolean = false) { + setKey(getFolderName(folder, path), value, commit) +} - // GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR - inline fun Context.getKey(path: String, defVal: T?): T? { - try { - val json: String = getSharedPrefs().getString(path, null) ?: return defVal - return json.toKotlinObject() +inline fun String.toKotlinObject(): T { + return mapper.readValue(this, T::class.java) +} + +fun String.toKotlinObject(valueType: Class): T { + return mapper.readValue(this, valueType) +} + +// GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR +inline fun Context.getKey(path: String, defVal: T?): T? { + try { + val json: String = getSharedPrefs().getString(path, null) ?: return defVal + // Log.d("DataStore", "getKey(Reified) $path raw: '$json' target: ${T::class.java.simpleName}") + return try { + val res = json.toKotlinObject() + // Log.d("DataStore", "getKey(Reified) $path parsed: '$res'") + res } catch (e: Exception) { - return null + // Log.w("DataStore", "getKey(Reified) $path parse fail: ${e.message}, trying fallback") + // FALLBACK: If JSON parsing fails, try manual conversion for common types + val fallback: T? = when { + T::class == String::class -> { + // If it's a string, try removing literal double quotes if they exist at start/end + if (json.startsWith("\"") && json.endsWith("\"") && json.length >= 2) { + json.substring(1, json.length - 1) as T + } else { + json as T + } + } + T::class == Boolean::class -> { + (json.lowercase() == "true" || json == "1") as T + } + T::class == Long::class -> { + json.toLongOrNull() as? T ?: defVal + } + T::class == Int::class -> { + json.toIntOrNull() as? T ?: defVal + } + else -> defVal + } + // Log.d("DataStore", "getKey(Reified) $path fallback: '$fallback'") + fallback } + } catch (e: Exception) { + Log.e("DataStore", "getKey(Reified) $path total fail: ${e.message}") + return defVal } +} - inline fun Context.getKey(path: String): T? { - return getKey(path, null) - } +inline fun Context.getKey(path: String): T? { + return getKey(path, null) +} - inline fun Context.getKey(folder: String, path: String): T? { - return getKey(getFolderName(folder, path), null) - } +inline fun Context.getKey(folder: String, path: String): T? { + return getKey(getFolderName(folder, path), null) +} - inline fun Context.getKey(folder: String, path: String, defVal: T?): T? { - return getKey(getFolderName(folder, path), defVal) ?: defVal - } +inline fun Context.getKey(folder: String, path: String, defVal: T?): T? { + return getKey(getFolderName(folder, path), defVal) ?: defVal } \ 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 217dc2a5205..2a37d46929c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.utils import android.content.Context +import com.lagradost.cloudstream3.utils.setKeyLocal import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.CloudStreamApp.Companion.context @@ -43,6 +44,7 @@ const val RESULT_WATCH_STATE_DATA = "result_watch_state_data" const val RESULT_SUBSCRIBED_STATE_DATA = "result_subscribed_state_data" const val RESULT_FAVORITES_STATE_DATA = "result_favorites_state_data" const val RESULT_RESUME_WATCHING = "result_resume_watching_2" // changed due to id changes + const val RESULT_RESUME_WATCHING_OLD = "result_resume_watching" const val RESULT_RESUME_WATCHING_HAS_MIGRATED = "result_resume_watching_migrated" const val RESULT_EPISODE = "result_episode" @@ -473,8 +475,10 @@ object DataStoreHelper { } fun deleteAllResumeStateIds() { - val folder = "$currentAccount/$RESULT_RESUME_WATCHING" - removeKeys(folder) + val ids = getAllResumeStateIds() + ids?.forEach { id -> + removeLastWatched(id) + } } fun deleteBookmarkedData(id: Int?) { @@ -491,6 +495,8 @@ object DataStoreHelper { } } + + private fun getAllResumeStateIdsOld(): List? { val folder = "$currentAccount/$RESULT_RESUME_WATCHING_OLD" return getKeys(folder)?.mapNotNull { @@ -526,7 +532,8 @@ object DataStoreHelper { updateTime: Long? = null, ) { if (parentId == null) return - setKey( + val time = updateTime ?: System.currentTimeMillis() + context?.setKeyLocal( "$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString(), VideoDownloadHelper.ResumeWatching( @@ -534,7 +541,7 @@ object DataStoreHelper { episodeId, episode, season, - updateTime ?: System.currentTimeMillis(), + time, isFromDownload ) ) @@ -550,6 +557,8 @@ object DataStoreHelper { removeKey("$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString()) } + + fun getLastWatched(id: Int?): VideoDownloadHelper.ResumeWatching? { if (id == null) return null return getKey( @@ -644,7 +653,8 @@ object DataStoreHelper { fun setViewPos(id: Int?, pos: Long, dur: Long) { if (id == null) return if (dur < 30_000) return // too short - setKey("$currentAccount/$VIDEO_POS_DUR", id.toString(), PosDur(pos, dur)) + // Use setKeyLocal to avoid triggering a sync every second + context?.setKeyLocal("$currentAccount/$VIDEO_POS_DUR", id.toString(), PosDur(pos, dur)) } /** Sets the position, duration, and resume data of an episode/movie, @@ -720,7 +730,7 @@ object DataStoreHelper { if (watchState == VideoWatchState.None) { removeKey("$currentAccount/$VIDEO_WATCH_STATE", id.toString()) } else { - setKey("$currentAccount/$VIDEO_WATCH_STATE", id.toString(), watchState) + context?.setKeyLocal("$currentAccount/$VIDEO_WATCH_STATE", id.toString(), watchState) } } @@ -778,6 +788,33 @@ object DataStoreHelper { } } + /** + * Deletes all local data that is part of the sync process. + * Used when switching accounts to ensure a clean state. + */ + fun deleteAllSyncableData() { + // Resume Watching + deleteAllResumeStateIds() + + // Bookmarks / Watch States + val watchStateIds = getAllWatchStateIds() + watchStateIds?.forEach { id -> + deleteBookmarkedData(id) + } + + // Subscriptions + getKeys("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA")?.forEach { removeKey(it) } + + // Favorites + getKeys("$currentAccount/$RESULT_FAVORITES_STATE_DATA")?.forEach { removeKey(it) } + + // Video Positions + removeKeys("$currentAccount/$VIDEO_POS_DUR") + + // Watch States (raw) + removeKeys("$currentAccount/$VIDEO_WATCH_STATE") + } + 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/DownloadFileWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt index 0b9b81e4024..0e065cd5c1c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt @@ -10,7 +10,7 @@ import androidx.work.WorkerParameters import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.getKey import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_INFO import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_PACKAGE import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadCheck @@ -101,4 +101,4 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt new file mode 100644 index 00000000000..450b63bb970 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt @@ -0,0 +1,837 @@ +package com.lagradost.cloudstream3.utils + +import android.app.Activity +import android.content.Context +import android.util.Log +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.SetOptions +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.Coroutines.main + +import com.lagradost.cloudstream3.plugins.RepositoryManager +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.plugins.PLUGINS_KEY +import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL +import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY +import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData + +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.plugins.PluginData +import kotlinx.coroutines.* +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Manages Firebase Firestore synchronization with generic tombstone support and Auth. + */ +object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { + private const val TAG = "FirestoreSync" + private const val SYNC_COLLECTION = "users" + private const val TIMESTAMPS_PREF = "sync_timestamps" + + // Internal keys + const val PENDING_PLUGINS_KEY = "pending_plugins_install" + const val IGNORED_PLUGINS_KEY = "firestore_ignored_plugins_key" + const val FIREBASE_PLUGINS_KEY = "firebase_plugins_list" + + private var db: FirebaseFirestore? = null + private var auth: FirebaseAuth? = null + private var syncListener: com.google.firebase.firestore.ListenerRegistration? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val isInitializing = AtomicBoolean(false) + private var isConnected = false + + private var appContext: Context? = null + private val throttleBatch = ConcurrentHashMap() + + var lastInitError: String? = null + private set + + private var isPluginsInitialized = false + private var isApplyingRemoteData = false + private var pendingRemotePluginJson: String? = null + + // Config keys in local DataStore + const val FIREBASE_API_KEY = "firebase_api_key" + const val FIREBASE_PROJECT_ID = "firebase_project_id" + const val FIREBASE_APP_ID = "firebase_app_id" + const val FIREBASE_ENABLED = "firebase_sync_enabled" + const val FIREBASE_LAST_SYNC = "firebase_last_sync" + + private const val ACCOUNTS_KEY = "data_store_helper/account" + private const val SETTINGS_SYNC_KEY = "settings" + private const val DATA_STORE_DUMP_KEY = "data_store_dump" + + // Ultra-granular sync control keys + const val SYNC_SETTING_APPEARANCE = "sync_setting_appearance" + const val SYNC_SETTING_PLAYER = "sync_setting_player" + const val SYNC_SETTING_DOWNLOADS = "sync_setting_downloads" + const val SYNC_SETTING_GENERAL = "sync_setting_general" + const val SYNC_SETTING_ACCOUNTS = "sync_setting_accounts" + const val SYNC_SETTING_BOOKMARKS = "sync_setting_bookmarks" + const val SYNC_SETTING_RESUME_WATCHING = "sync_setting_resume_watching" + const val SYNC_SETTING_REPOSITORIES = "sync_setting_repositories" + const val SYNC_SETTING_PLUGINS = "sync_setting_plugins" + const val SYNC_SETTING_HOMEPAGE_API = "sync_setting_homepage_api" + const val SYNC_SETTING_PINNED_PROVIDERS = "sync_setting_pinned_providers" + + // Generic Wrapper for all sync data + data class SyncPayload( + val v: Any?, // Value (JSON string or primitive) + val t: Long, // Timestamp + val d: Boolean = false // IsDeleted (Tombstone) + ) + + data class SyncConfig( + val apiKey: String, + val projectId: String, + val appId: String + ) + + // --- Auth Public API --- + + // Auth is now handled by AccountManager (FirebaseApi) + // We just listen to the state in initialize() + + fun isLogged(): Boolean = auth?.currentUser != null + + fun getUserEmail(): String? = auth?.currentUser?.email + + fun getFirebaseAuth(): FirebaseAuth { + return auth ?: FirebaseAuth.getInstance() + } + + + // --- Initialization --- + + override fun onStop(owner: androidx.lifecycle.LifecycleOwner) { + super.onStop(owner) + val ctx = appContext ?: return + if (isEnabled(ctx)) { + scope.launch { + flushBatch() + } + } + } + + fun isEnabled(context: Context): Boolean { + return context.getKey(FIREBASE_ENABLED) ?: false + } + + fun initialize(context: Context) { + appContext = context.applicationContext + + com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread { + try { + androidx.lifecycle.ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } catch (_: Exception) { } + } + + if (!isEnabled(context)) return + + val config = SyncConfig( + apiKey = context.getKey(FIREBASE_API_KEY) ?: "", + projectId = context.getKey(FIREBASE_PROJECT_ID) ?: "", + appId = context.getKey(FIREBASE_APP_ID) ?: "" + ) + + if (config.apiKey.isNotBlank() && config.projectId.isNotBlank()) { + initialize(context, config) + } + } + + fun initialize(context: Context, config: SyncConfig) { + if (isInitializing.getAndSet(true)) return + appContext = context.applicationContext + + scope.launch { + initializeInternal(context, config) + } + } + + private suspend fun initializeInternal(context: Context, config: SyncConfig) { + try { + val options = FirebaseOptions.Builder() + .setApiKey(config.apiKey) + .setProjectId(config.projectId) + .setApplicationId(config.appId) + .build() + + val appName = "sync_${config.projectId.replace(":", "_")}" + val app = try { + FirebaseApp.getInstance(appName) + } catch (_: Exception) { + FirebaseApp.initializeApp(context, options, appName) + } + + db = FirebaseFirestore.getInstance(app) + auth = FirebaseAuth.getInstance(app) + isConnected = true + + // Save config + context.setKey(FIREBASE_API_KEY, config.apiKey) + context.setKey(FIREBASE_PROJECT_ID, config.projectId) + context.setKey(FIREBASE_APP_ID, config.appId) + context.setKey(FIREBASE_ENABLED, true) + + // Auth State Listener + auth?.addAuthStateListener { firebaseAuth -> + val user = firebaseAuth.currentUser + if (user != null) { + setupRealtimeListener(context, user.uid) + } + // Refresh UI when auth state changes + main { MainActivity.syncUpdatedEvent.invoke(true) } + } + + } catch (e: Exception) { + lastInitError = e.message + // log("Init Error: ${e.message}") + } finally { + isInitializing.set(false) + } + } + + fun switchAccount(uid: String) { + val context = appContext ?: return + // log("Switching sync account to UID: $uid") + + // Stop current listener + syncListener?.remove() + syncListener = null + + // Overwrite local data with a clean slate + DataStoreHelper.deleteAllSyncableData() + + // Start listener for new account if not logging out + if (uid.isNotBlank()) { + setupRealtimeListener(context, uid) + } + } + + private fun isPluginManagerReady(): Boolean { + return PluginManager.loadedLocalPlugins && PluginManager.loadedOnlinePlugins + } + + private fun setupRealtimeListener(context: Context, uid: String) { + syncListener?.remove() + syncListener = db?.collection(SYNC_COLLECTION)?.document(uid)?.addSnapshotListener { snapshot, e -> + if (e != null) { + // log("Listen error: ${e.message}") + return@addSnapshotListener + } + if (snapshot != null && snapshot.exists()) { + scope.launch { + applyRemoteData(context, snapshot) + } + } else { + // New user / empty doc -> Push local + // Only allow initialization if the scan is actually done + if (isPluginManagerReady()) { + isPluginsInitialized = true + scope.launch { + pushAllLocalData(context, immediate = true) + } + } + } + } + } + + // --- Core Logic --- + + // Local Timestamp Management + private fun setLocalTimestamp(context: Context, key: String, timestamp: Long) { + context.getSharedPreferences(TIMESTAMPS_PREF, Context.MODE_PRIVATE).edit() + .putLong(key, timestamp) + .apply() + } + + private fun getLocalTimestamp(context: Context, key: String): Long { + return context.getSharedPreferences(TIMESTAMPS_PREF, Context.MODE_PRIVATE).getLong(key, 0L) + } + + // Push: Write (Update or Create) + // Called from DataStore.setKey without context parameter + fun pushWrite(key: String, value: Any?) { + val ctx = appContext ?: return + if (!isEnabled(ctx)) return + if (isApplyingRemoteData) return + + // Intercept Plugin Check - MUST handle this before isInternalKey + if (key == PLUGINS_KEY_LOCAL || key == PLUGINS_KEY) { + if (!isPluginManagerReady()) return + scope.launch { + updatePluginList(ctx) + } + return + } + + if (isInternalKey(key)) return + if (!shouldSync(ctx, key)) return + + throttleBatch[key] = value + triggerFlush() + } + + private fun shouldSync(context: Context, key: String): Boolean { + // Essential toggles themselves always sync + if (key.startsWith("sync_setting_")) return true + if (key == FIREBASE_LAST_SYNC) return true + + // Granular toggles + if (key.startsWith("app_") || key.startsWith("ui_")) return context.getKey(SYNC_SETTING_APPEARANCE) ?: true + if (key.startsWith("player_")) return context.getKey(SYNC_SETTING_PLAYER) ?: true + if (key.startsWith("download_")) return context.getKey(SYNC_SETTING_DOWNLOADS) ?: true + if (key.startsWith("data_store_helper/account")) return context.getKey(SYNC_SETTING_ACCOUNTS) ?: true + if (key.startsWith("result_resume_watching")) return context.getKey(SYNC_SETTING_RESUME_WATCHING) ?: true + if (key.contains("bookmark")) return context.getKey(SYNC_SETTING_BOOKMARKS) ?: true + if (key == REPOSITORIES_KEY) return context.getKey(SYNC_SETTING_REPOSITORIES) ?: true + if (key == FIREBASE_PLUGINS_KEY) return context.getKey(SYNC_SETTING_PLUGINS) ?: true + if (key == USER_SELECTED_HOMEPAGE_API) return context.getKey(SYNC_SETTING_HOMEPAGE_API) ?: true + + return context.getKey(SYNC_SETTING_GENERAL) ?: true + } + + // --- Plugin Merge Logic --- + private var cachedRemotePlugins: MutableList = mutableListOf() + + // Called when Any Local List changes (Install/Uninstall) OR when we want to push specific updates + private suspend fun updatePluginList(context: Context) { + if (!isPluginManagerReady()) { + // log("Sync: Skipping plugin update push (PluginManager not ready)") + return + } + val localList = PluginManager.getPluginsLocal(includeDeleted = true).toList() + val onlineList = PluginManager.getPluginsOnline(includeDeleted = true).toList() + val allLocal = localList + onlineList + + // 1. Merge Local into Cached Remote (Additions/Updates) + var changed = false + + allLocal.forEach { local -> + if (local.url.isNullOrBlank()) return@forEach + + val existingIndex = cachedRemotePlugins.indexOfFirst { isMatchingPlugin(it, local) } + if (existingIndex != -1) { + val existing = cachedRemotePlugins[existingIndex] + // If local is active but remote is deleted, reactivate remote + if (existing.isDeleted && !local.isDeleted) { + cachedRemotePlugins[existingIndex] = existing.copy(isDeleted = false, version = local.version, addedDate = System.currentTimeMillis()) + changed = true + } else if (!existing.isDeleted && local.isDeleted) { + // If local is deleted but remote is active, mark remote as deleted + cachedRemotePlugins[existingIndex] = existing.copy(isDeleted = true, addedDate = System.currentTimeMillis()) + changed = true + } + } else if (!local.isDeleted) { + // New plugin, not in cloud yet + cachedRemotePlugins.add(local.copy(isOnline = true, isDeleted = false)) + changed = true + } + } + + // 2. Sync deletions from local metadata (plugins explicitly marked deleted) + allLocal.filter { it.isDeleted }.forEach { local -> + val existingIndex = cachedRemotePlugins.indexOfFirst { isMatchingPlugin(it, local) } + if (existingIndex != -1 && !cachedRemotePlugins[existingIndex].isDeleted) { + // log("Sync: Syncing local uninstall for ${local.internalName} to cloud") + cachedRemotePlugins[existingIndex] = cachedRemotePlugins[existingIndex].copy(isDeleted = true, addedDate = System.currentTimeMillis()) + changed = true + } + } + + if (changed) { + if (!isPluginsInitialized) { + // log("Sync: Ignoring plugin list push (not initialized yet)") + return + } + // log("Sync: Pushing updated plugin list to cloud (${cachedRemotePlugins.count { !it.isDeleted }} active).") + pushWriteDirect(FIREBASE_PLUGINS_KEY, cachedRemotePlugins.toJson()) + } else { + // log("Sync: No changes to plugin list push.") + } + } + + suspend fun notifyPluginDeleted(internalName: String) { + val idx = cachedRemotePlugins.indexOfFirst { it.internalName.trim().equals(internalName.trim(), ignoreCase = true) } + if (idx != -1) { + val existing = cachedRemotePlugins[idx] + if (!existing.isDeleted) { + cachedRemotePlugins[idx] = existing.copy(isDeleted = true, addedDate = System.currentTimeMillis()) + // log("Marking plugin $internalName as DELETED in sync.") + if (!isPluginsInitialized) { + // log("Sync: Ignoring plugin list push (not initialized yet)") + return + } + pushWriteDirect(FIREBASE_PLUGINS_KEY, cachedRemotePlugins.toJson()) + } + } + } + + private fun pushWriteDirect(key: String, value: Any?) { + // log("Sync: Queuing direct push for $key") + throttleBatch[key] = value + triggerFlush() + } + + // Push: Delete + // Called from DataStore.removeKey without context parameter + fun pushDelete(key: String) { + val ctx = appContext ?: return + if (!isEnabled(ctx) || isInternalKey(key)) return + if (!shouldSync(ctx, key)) return + + // Intercept Plugin Check + if (key == PLUGINS_KEY_LOCAL) return + + throttleBatch[key] = SyncPayload(null, System.currentTimeMillis(), true) + triggerFlush() + } + + private var flushJob: Job? = null + private fun triggerFlush() { + if (flushJob?.isActive == true) return + flushJob = scope.launch { + delay(2000) // 2s debounce + flushBatch() + } + } + + private suspend fun flushBatch() { + val uid = auth?.currentUser?.uid ?: return + val ctx = appContext ?: return + val updates = mutableMapOf() + val now = System.currentTimeMillis() + + // Grab snapshot of batch + val currentBatch = HashMap(throttleBatch) + throttleBatch.clear() + + if (currentBatch.isEmpty()) return + + currentBatch.forEach { (key, value) -> + if (value is SyncPayload) { + // Already a payload (delete) + updates[key] = value + } else { + // Value update + updates[key] = SyncPayload(value, now, false) + } + } + + updates["last_sync"] = now + ctx.setKey(FIREBASE_LAST_SYNC, now) + + db?.collection(SYNC_COLLECTION)?.document(uid) + ?.set(updates, SetOptions.merge()) + } + private suspend fun applyRemoteData(context: Context, snapshot: DocumentSnapshot) { + if (isApplyingRemoteData) return + isApplyingRemoteData = true + try { + val remoteMap = snapshot.data ?: return + + hydrateCachedPlugins(context, remoteMap) + applyRemotePayloads(context, remoteMap) + + val remoteSyncTime = (remoteMap["last_sync"] as? Number)?.toLong() ?: 0L + if (remoteSyncTime > 0) { + context.setKey(FIREBASE_LAST_SYNC, remoteSyncTime) + } + + if (pendingRemotePluginJson == null) { + isPluginsInitialized = true + } + + main { + MainActivity.syncUpdatedEvent.invoke(true) + } + } finally { + isApplyingRemoteData = false + } + } + + private fun hydrateCachedPlugins(context: Context, remoteMap: Map) { + if (!remoteMap.containsKey(FIREBASE_PLUGINS_KEY)) return + try { + val rawPayload = remoteMap[FIREBASE_PLUGINS_KEY] + if (rawPayload is Map<*, *>) { + val v = rawPayload["v"] as? String + if (v != null) { + cachedRemotePlugins = parseJson>(v).toMutableList() + handleRemotePlugins(context, v) + } + } + } catch (_: Exception) { } + } + + private fun applyRemotePayloads(context: Context, remoteMap: Map) { + remoteMap.forEach { (key, rawPayload) -> + if (key == "last_sync" || key == FIREBASE_PLUGINS_KEY) return@forEach + + try { + if (rawPayload !is Map<*, *>) return@forEach + + val v = rawPayload["v"] + val t = (rawPayload["t"] as? Number)?.toLong() ?: 0L + val d = rawPayload["d"] as? Boolean ?: false + + val localT = getLocalTimestamp(context, key) + + if (t > localT) { + applyPayload(context, key, v, d) + setLocalTimestamp(context, key, t) + } + } catch (_: Exception) { } + } + } + + // Handles the actual application of a single Key-Value-Tombstone triplet + private fun applyPayload(context: Context, key: String, value: Any?, isDeleted: Boolean) { + if (isDeleted) { + context.removeKeyLocal(key) + return + } + + // Special Handling for Plugins (The Shared Master List) + if (key == FIREBASE_PLUGINS_KEY) { + val json = value as? String ?: return + + // Update Cache + try { + val list = parseJson>(json).toMutableList() + cachedRemotePlugins = list + } catch(_:Exception) {} + + // Process + handleRemotePlugins(context, json) + return + } + + // Ignore direct PLUGINS_KEY_LOCAL writes from remote + if (key == PLUGINS_KEY_LOCAL) return + + // Default Apply + if (value is String) { + context.setKeyLocal(key, value) + } else if (value != null) { + context.setKeyLocal(key, value.toString()) + } + } + + // --- Plugin Safety --- + + private fun isMatchingPlugin(p1: PluginData, local: PluginData): Boolean { + val name1 = p1.internalName.trim() + val name2 = local.internalName.trim() + + // Match by internal name (case-insensitive) + if (name1.equals(name2, ignoreCase = true)) return true + + // Secondary match by URL if available (must be exact) + if (p1.url?.isNotBlank() == true && p1.url == local.url) return true + + return false + } + + fun getPendingPlugins(context: Context): List { + val json = context.getSharedPrefs().getString(PENDING_PLUGINS_KEY, "[]") ?: "[]" + return try { + val pending = parseJson>(json).toList() + val localPlugins = PluginManager.getPluginsLocal() + val onlinePlugins = PluginManager.getPluginsOnline() + val allLocal = localPlugins + onlinePlugins + + val res = pending.filter { pendingPlugin -> + allLocal.none { local -> isMatchingPlugin(pendingPlugin, local) } + } + // if (res.isNotEmpty()) log("Sync: detected ${res.size} pending plugins not installed locally.") + res + } catch(e:Exception) { emptyList() } + } + + suspend fun installPendingPlugin(activity: Activity, plugin: PluginData): Boolean { + val context = activity.applicationContext + val savedRepos = context.getKey>(REPOSITORIES_KEY) ?: emptyArray() + val allRepos = (savedRepos + RepositoryManager.PREBUILT_REPOSITORIES).distinctBy { it.url } + + // log("Searching repositories for ${plugin.internalName}...") + + for (repo in allRepos) { + val plugins = RepositoryManager.getRepoPlugins(repo.url) ?: continue + val match = plugins.firstOrNull { it.second.internalName == plugin.internalName } + + if (match != null) { + // log("Found in ${repo.name}. Installing...") + val success = PluginManager.downloadPlugin( + activity, + match.second.url, + match.second.internalName, + repo.url, + true + ) + + if (success) { + removeFromPending(context, plugin) + return true + } + } + } + + // log("Could not find repository for plugin: ${plugin.internalName}") + CommonActivity.showToast(activity, activity.getString(com.lagradost.cloudstream3.R.string.sync_plugin_repo_not_found, plugin.internalName), 1) + return false + } + + suspend fun installAllPending(activity: Activity) { + val context = activity.applicationContext + val pending = getPendingPlugins(context) + if (pending.isEmpty()) return + + val savedRepos = context.getKey>(REPOSITORIES_KEY) ?: emptyArray() + val allRepos = (savedRepos + RepositoryManager.PREBUILT_REPOSITORIES).distinctBy { it.url } + + val onlineMap = mutableMapOf>() + + allRepos.forEach { repo -> + RepositoryManager.getRepoPlugins(repo.url)?.forEach { (repoUrl, sitePlugin) -> + onlineMap[sitePlugin.internalName] = Pair(sitePlugin.url, repoUrl) + } + } + + var installedCount = 0 + val remaining = mutableListOf() + + pending.forEach { p -> + val match = onlineMap[p.internalName] + if (match != null) { + val (url, repoUrl) = match + val success = PluginManager.downloadPlugin(activity, url, p.internalName, repoUrl, true) + if (success) installedCount++ else remaining.add(p) + } else { + remaining.add(p) + } + } + + // Update pending list with failures/missing + context.setKeyLocal(PENDING_PLUGINS_KEY, remaining.toJson()) + + if (installedCount > 0) { + CommonActivity.showToast(activity, activity.getString(com.lagradost.cloudstream3.R.string.sync_plugins_installed, installedCount), 0) + } + if (remaining.isNotEmpty()) { + CommonActivity.showToast(activity, activity.getString(com.lagradost.cloudstream3.R.string.sync_plugins_install_failed, remaining.size), 1) + } + } + + private fun removeFromPending(context: Context, plugin: PluginData) { + val pending = getPendingPlugins(context).toMutableList() + pending.removeAll { it.internalName == plugin.internalName } + context.setKeyLocal(PENDING_PLUGINS_KEY, pending.toJson()) + } + + fun ignorePendingPlugin(context: Context, plugin: PluginData) { + removeFromPending(context, plugin) + + val ignoredJson = context.getSharedPrefs().getString(IGNORED_PLUGINS_KEY, "[]") ?: "[]" + val ignoredList = try { + parseJson>(ignoredJson).toMutableSet() + } catch(_:Exception) { mutableSetOf() } + + ignoredList.add(plugin.internalName) + context.setKeyLocal(IGNORED_PLUGINS_KEY, ignoredList.toJson()) + } + + fun ignoreAllPendingPlugins(context: Context) { + val pending = getPendingPlugins(context) + if (pending.isNotEmpty()) { + val ignoredJson = context.getSharedPrefs().getString(IGNORED_PLUGINS_KEY, "[]") ?: "[]" + val ignoredList = try { + parseJson>(ignoredJson).toMutableSet() + } catch(_:Exception) { mutableSetOf() } + + pending.forEach { ignoredList.add(it.internalName) } + + context.setKeyLocal(IGNORED_PLUGINS_KEY, ignoredList.toJson()) + context.setKeyLocal(PENDING_PLUGINS_KEY, "[]") + } + } + + private fun handleRemotePlugins(context: Context, remoteJson: String) { + try { + val remoteList = parseJson>(remoteJson).toList() + cachedRemotePlugins = remoteList.toMutableList() + + val json = context.getSharedPrefs().getString(PENDING_PLUGINS_KEY, "[]") ?: "[]" + val rawPending = try { + parseJson>(json).toMutableList() + } catch(_:Exception) { mutableListOf() } + + val installedPlugins = (PluginManager.getPluginsLocal() + PluginManager.getPluginsOnline()).toList() + val ignoredList = getIgnoredPlugins(context) + + val changed = processRemotePluginChanges(context, remoteList, installedPlugins, rawPending, ignoredList) + cleanupPendingPlugins(remoteList, rawPending) + + if (changed) { + context.setKeyLocal(PENDING_PLUGINS_KEY, rawPending.toJson()) + main { MainActivity.syncUpdatedEvent.invoke(true) } + } + + updateOnlinePluginList(context, remoteList) + } catch (_: Exception) { } + } + + private fun getIgnoredPlugins(context: Context): Set { + val ignoredJson = context.getSharedPrefs().getString(IGNORED_PLUGINS_KEY, "[]") ?: "[]" + return try { + parseJson>(ignoredJson).map { it.trim() }.toSet() + } catch(_:Exception) { emptySet() } + } + + private fun processRemotePluginChanges( + context: Context, + remoteList: List, + installedPlugins: List, + rawPending: MutableList, + ignoredList: Set + ): Boolean { + var changed = false + remoteList.forEach { remote -> + val isLocal = installedPlugins.firstOrNull { isMatchingPlugin(remote, it) } + if (remote.isDeleted) { + if (isLocal != null) { + val file = File(isLocal.filePath) + if (file.exists()) { + file.delete() + val updatedLocalPlugins = PluginManager.getPluginsLocal() + .filter { it.filePath != isLocal.filePath } + .toTypedArray() + context.setKeyLocal(PLUGINS_KEY_LOCAL, updatedLocalPlugins) + } + } + if (rawPending.removeIf { isMatchingPlugin(remote, it) }) changed = true + } else { + if (isLocal == null) { + val cleanName = remote.internalName.trim() + if (!ignoredList.contains(cleanName) && rawPending.none { isMatchingPlugin(remote, it) }) { + rawPending.add(remote) + changed = true + } + } else if (rawPending.removeIf { isMatchingPlugin(remote, it) }) { + changed = true + } + } + } + return changed + } + + private fun cleanupPendingPlugins(remoteList: List, rawPending: MutableList) { + rawPending.retainAll { pending -> + remoteList.any { remote -> isMatchingPlugin(remote, pending) } + } + } + + private fun updateOnlinePluginList(context: Context, remoteList: List) { + val onlinePlugins = PluginManager.getPluginsOnline() + val currentOnlinePlugins = onlinePlugins.toMutableList() + remoteList.forEach { remote -> + val match = currentOnlinePlugins.indexOfFirst { isMatchingPlugin(remote, it) } + if (remote.isDeleted) { + if (match != -1) currentOnlinePlugins.removeAt(match) + } else if (match != -1) { + currentOnlinePlugins[match] = remote.copy( + filePath = currentOnlinePlugins[match].filePath, + isOnline = true + ) + } + } + + if (remoteList.isNotEmpty()) { + context.setKeyLocal(PLUGINS_KEY, currentOnlinePlugins.toTypedArray()) + } + + // Signal initialized + isPluginsInitialized = true + } + + /** + * Called by MainActivity after various plugin loading events. + * Applies any deferred remote plugin data that arrived before PluginManager was ready. + */ + fun onPluginsReady(context: Context) { + if (!PluginManager.loadedLocalPlugins || !PluginManager.loadedOnlinePlugins) { + // log("Sync: Plugins not fully ready yet (Local: ${PluginManager.loadedLocalPlugins}, Online: ${PluginManager.loadedOnlinePlugins})") + return + } + + if (isPluginsInitialized) return // Already signaled + + // Crucial: Mark as initialized so we can now push local changes to cloud + isPluginsInitialized = true + // log("Sync: Plugins are now fully ready (Both Local and Online loaded).") + + val pending = pendingRemotePluginJson + if (pending != null) { + // log("Applying deferred remote plugin data.") + pendingRemotePluginJson = null + handleRemotePlugins(context, pending) + } else { + // Even if no remote data, we should now push our local list to ensure cloud is synced + scope.launch { + updatePluginList(context) + } + } + } + + // --- Helpers --- + + private fun isInternalKey(key: String): Boolean { + if (key == FIREBASE_PLUGINS_KEY) return false // Explicitly allow sync list + if (key.startsWith("firebase_")) return true + if (key.startsWith("firestore_")) return true + if (key == PENDING_PLUGINS_KEY) return true + if (key == PLUGINS_KEY) return true // PLUGINS_KEY is managed via FIREBASE_PLUGINS_KEY + return false + } + + suspend fun pushAllLocalData(context: Context, immediate: Boolean = false) { + if (auth?.currentUser == null) return + val prefs = context.getSharedPrefs() + prefs.all.forEach { (k, v) -> + if (!isInternalKey(k) && k != PLUGINS_KEY_LOCAL && k != PLUGINS_KEY && v != null) { + pushWrite(k, v) + } + } + + // Unified Plugin Push + updatePluginList(context) + + if (immediate) flushBatch() + } + + fun syncNow(context: Context) { + scope.launch { + pushAllLocalData(context, true) + } + } + + fun isOnline(): Boolean { + return isConnected + } + + fun getLastSyncTime(context: Context): Long? { + val time = context.getKey(FIREBASE_LAST_SYNC) + return if (time == 0L) null else time + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt index feecbe312df..b9b8e44fd01 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt @@ -15,8 +15,8 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.base64Encode import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.getKey +import com.lagradost.cloudstream3.utils.setKey import java.net.URLEncoder const val PROGRAM_ID_LIST_KEY = "persistent_program_ids" @@ -161,4 +161,4 @@ object TvChannelUtils { } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index cdda1186818..a12720cf8b4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -45,8 +45,8 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.services.VideoDownloadService import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.getKey +import com.lagradost.cloudstream3.utils.removeKey import com.lagradost.cloudstream3.utils.SubtitleUtils.deleteMatchingSubtitles import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.safefile.MediaFileContentType diff --git a/app/src/main/res/drawable/ic_baseline_cloud_queue_24.xml b/app/src/main/res/drawable/ic_baseline_cloud_queue_24.xml new file mode 100644 index 00000000000..86e4f2dc255 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_cloud_queue_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_content_copy_24.xml b/app/src/main/res/drawable/ic_baseline_content_copy_24.xml new file mode 100644 index 00000000000..544e3d64567 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_content_copy_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_sync_24.xml b/app/src/main/res/drawable/ic_baseline_sync_24.xml new file mode 100644 index 00000000000..00e4bc15113 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_sync_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/account_single.xml b/app/src/main/res/layout/account_single.xml index c4f7fa39479..ca7f23e2e86 100644 --- a/app/src/main/res/layout/account_single.xml +++ b/app/src/main/res/layout/account_single.xml @@ -25,7 +25,20 @@ + + diff --git a/app/src/main/res/layout/fragment_setup_sync.xml b/app/src/main/res/layout/fragment_setup_sync.xml new file mode 100644 index 00000000000..e64751824eb --- /dev/null +++ b/app/src/main/res/layout/fragment_setup_sync.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_sync_settings.xml b/app/src/main/res/layout/fragment_sync_settings.xml new file mode 100644 index 00000000000..bbd7d5dd3b4 --- /dev/null +++ b/app/src/main/res/layout/fragment_sync_settings.xml @@ -0,0 +1,585 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_sync_plugin.xml b/app/src/main/res/layout/item_sync_plugin.xml new file mode 100644 index 00000000000..a1dbca32030 --- /dev/null +++ b/app/src/main/res/layout/item_sync_plugin.xml @@ -0,0 +1,41 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/main_settings.xml b/app/src/main/res/layout/main_settings.xml index ba377455440..b134a2f0f51 100644 --- a/app/src/main/res/layout/main_settings.xml +++ b/app/src/main/res/layout/main_settings.xml @@ -99,8 +99,11 @@ android:id="@+id/settings_credits" style="@style/SettingsItem" android:nextFocusUp="@id/settings_updates" + android:nextFocusDown="@id/settings_extensions" android:text="@string/category_account" /> + + + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index 784fc515e8f..473bee53ad2 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -413,6 +413,13 @@ app:exitAnim="@anim/exit_anim" app:popEnterAnim="@anim/enter_anim" app:popExitAnim="@anim/exit_anim" /> + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 48b69232abe..38713197cb9 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -11,6 +11,7 @@ #111111 #1C1C20 #161616 + #EF5350 #e9eaee #9ba0a4 @@ -122,4 +123,7 @@ #ea596e #FF9800 #B3000000 + #4CAF50 + #FFC107 + #F44336 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4f3a4f5d836..d8d48355dee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,9 @@ + Firebase + Firebase + Firebase + %1$s Ep %2$d Cast: %s @@ -180,6 +184,7 @@ Search Library Accounts and Security + Firebase Updates and Backup Info Advanced Search @@ -763,4 +768,82 @@ Top left Top center Top right + Could not find source repository for %s + Installed %d plugins. + Failed to find/install %d plugins. + + + Syncing… + No sync logs available. + Sync logs copied to clipboard. + Installing plugins… + Pending plugins cleared. + Connecting… + Connected + Login needed + Error: %s + Disconnected + Signed in as %s + Manage Accounts + Not logged in + Log in via Accounts + New plugins detected (%d) + + + Account + New Plugins Detected + WARN + The following plugins were synced from another device. Install them? + Install All + Ignore + Database Configuration + API Key + Project ID + Application ID + Connect Database + App Configuration + My Library & Data + Extensions + Interface Personalization + Sync Status + Status: + Last Sync: + Never + Sync Now + + + Appearance + Sync theme, colors, and layout preferences. + Player Settings + Sync subtitle styles, player gestures, and video quality. + Downloads + Sync download paths and parallel download limits. + General Settings + Sync miscellaneous app-wide preferences. + User Profiles + Sync profile names, avatars, and linked accounts. + Bookmarks + Sync your watchlist and favorite items. + Watch Progress + Sync where you left off on every movie/episode. + Source Repositories + Sync the list of added plugin repositories. + Installed Plugins + Sync which online plugins are installed. + Home Provider + Sync which homepage source is currently active. + Pinned Providers + Sync your pinned providers on the home screen. + + + Firebase + With Firebase, you can sync all your settings with your other devices. + Yes, Setup Sync + Account Connected! + You are ready to sync. + Could not open Accounts settings. + Unknown User + Login needed + Error: %s + Install diff --git a/app/src/main/res/xml/settings_account.xml b/app/src/main/res/xml/settings_account.xml index 3b8ce22948b..248e7570dad 100644 --- a/app/src/main/res/xml/settings_account.xml +++ b/app/src/main/res/xml/settings_account.xml @@ -29,6 +29,11 @@ android:icon="@drawable/subdl_logo_big" android:key="@string/subdl_key" /> + +