From ddea7d5a06d2fcd9afaf94f8518da8d160e352f1 Mon Sep 17 00:00:00 2001 From: rappc87 Date: Sat, 10 Jan 2026 18:05:05 +0300 Subject: [PATCH 01/13] Updated --- app/build.gradle.kts | 13 + .../lagradost/cloudstream3/MainActivity.kt | 18 +- .../cloudstream3/plugins/PluginManager.kt | 69 +- .../cloudstream3/plugins/RepositoryManager.kt | 6 +- .../ui/settings/SettingsAccount.kt | 6 + .../ui/settings/SyncSettingsFragment.kt | 113 +++ .../lagradost/cloudstream3/utils/DataStore.kt | 58 +- .../cloudstream3/utils/DataStoreHelper.kt | 33 +- .../utils/FirestoreSyncManager.kt | 673 ++++++++++++++++++ .../drawable/ic_baseline_cloud_queue_24.xml | 10 + .../drawable/ic_baseline_content_copy_24.xml | 10 + .../main/res/drawable/ic_baseline_sync_24.xml | 10 + .../res/layout/fragment_sync_settings.xml | 204 ++++++ app/src/main/res/layout/main_settings.xml | 1 + .../main/res/navigation/mobile_navigation.xml | 16 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/xml/settings_account.xml | 5 + build.gradle.kts | 1 + deps.txt | Bin 0 -> 222 bytes gradle.properties | 3 + gradle/libs.versions.toml | 10 +- 21 files changed, 1234 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt create mode 100644 app/src/main/res/drawable/ic_baseline_cloud_queue_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_content_copy_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_sync_24.xml create mode 100644 app/src/main/res/layout/fragment_sync_settings.xml create mode 100644 deps.txt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 41e8fc0a01a..7dd05622f0b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.dokka) alias(libs.plugins.kotlin.android) + // alias(libs.plugins.google.services) // We use manual Firebase initialization } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) @@ -217,6 +218,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.analytics) + + 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. diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 1caaaa4c693..40c99fb622e 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 @@ -138,6 +141,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 @@ -620,6 +624,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa override fun onResume() { super.onResume() + if (FirestoreSyncManager.isEnabled(this)) { + FirestoreSyncManager.pushAllLocalData(this) + } afterPluginsLoadedEvent += ::onAllPluginsLoaded setActivityInstance(this) try { @@ -633,7 +640,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa override fun onPause() { super.onPause() - + if (FirestoreSyncManager.isEnabled(this)) { + FirestoreSyncManager.pushAllLocalData(this) + } // Start any delayed updates if (ApkInstaller.delayedInstaller?.startInstallation() == true) { Toast.makeText(this, R.string.update_started, Toast.LENGTH_LONG).show() @@ -1191,6 +1200,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } catch (t: Throwable) { logError(t) } + + lifecycleScope.launch(Dispatchers.IO) { + FirestoreSyncManager.initialize(this@MainActivity) + } window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) updateTv() @@ -1653,6 +1666,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa val navController = navHostFragment.navController navController.addOnDestinationChangedListener { _: NavController, navDestination: NavDestination, bundle: Bundle? -> + if (FirestoreSyncManager.isEnabled(this@MainActivity)) { + FirestoreSyncManager.pushAllLocalData(this@MainActivity) + } // Intercept search and add a query updateNavBar(navDestination) if (navDestination.matchDestination(R.id.navigation_search) && !nextSearchQuery.isNullOrBlank()) { 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..1e16807b96e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -75,6 +75,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,14 +111,29 @@ object PluginManager { private var hasCreatedNotChanel = false + /** + * Store data about the plugin for fetching later + * */ + fun getPluginsOnline(): Array { + return (getKey>(PLUGINS_KEY) ?: emptyArray()).filter { !it.isDeleted }.toTypedArray() + } + + // Helper for internal use to preserve tombstones + private 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 + 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()) setKey(PLUGINS_KEY, newPlugins) } else { val plugins = getPluginsLocal() @@ -129,8 +146,12 @@ 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) @@ -140,14 +161,20 @@ object PluginManager { 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) } } @@ -165,9 +192,7 @@ object PluginManager { } - fun getPluginsOnline(): Array { - return getKey(PLUGINS_KEY) ?: emptyArray() - } + fun getPluginsLocal(): Array { return getKey(PLUGINS_KEY_LOCAL) ?: emptyArray() @@ -360,14 +385,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 +402,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)) { @@ -768,7 +800,8 @@ object PluginManager { pluginUrl, true, newFile.absolutePath, - PLUGIN_VERSION_NOT_SET + PLUGIN_VERSION_NOT_SET, + System.currentTimeMillis() ) return if (loadPlugin) { 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/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 7c24cd7a9a9..dea648af215 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 @@ -63,6 +63,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 @@ -486,5 +487,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/SyncSettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt new file mode 100644 index 00000000000..856392e398e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt @@ -0,0 +1,113 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.graphics.Color +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.databinding.FragmentSyncSettingsBinding +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.FirestoreSyncManager +import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper +import com.lagradost.cloudstream3.utils.txt +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class SyncSettingsFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSyncSettingsBinding::inflate) +) { + override fun fixLayout(view: View) { + // No special layout fixes needed currently + } + + override fun onBindingCreated(binding: FragmentSyncSettingsBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + binding?.syncToolbar?.setNavigationOnClickListener { + activity?.onBackPressedDispatcher?.onBackPressed() + } + + setupInputs() + updateStatusUI() + + binding?.syncConnectBtn?.setOnClickListener { + connect() + } + + binding?.syncNowBtn?.setOnClickListener { + showToast("Sync started...") + FirestoreSyncManager.pushAllLocalData(requireContext()) + // Brief delay to allow sync to happen then update UI + view?.postDelayed({ updateStatusUI() }, 2000) + } + + binding.syncCopyLogsBtn.setOnClickListener { + val logs = FirestoreSyncManager.getLogs() + if (logs.isBlank()) { + showToast("No logs available yet.") + } else { + clipboardHelper(txt("Sync Logs"), logs) + showToast("Logs copied to clipboard") + } + } + } + + private fun setupInputs() { + val context = requireContext() + binding?.apply { + 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 connect() { + val config = FirestoreSyncManager.SyncConfig( + apiKey = binding?.syncApiKey?.text?.toString() ?: "", + projectId = binding?.syncProjectId?.text?.toString() ?: "", + appId = binding?.syncAppId?.text?.toString() ?: "" + ) + + FirestoreSyncManager.initialize(requireContext(), config) + showToast("Connecting to Firebase...") + // Delay update to allow initialization to start + view?.postDelayed({ updateStatusUI() }, 3000) + } + + private fun updateStatusUI() { + val enabled = FirestoreSyncManager.isEnabled(requireContext()) + binding?.syncStatusCard?.isVisible = enabled + if (enabled) { + val isOnline = FirestoreSyncManager.isOnline() + binding?.syncStatusText?.text = if (isOnline) "Connected" else "Disconnected (Check Logs)" + binding?.syncStatusText?.setTextColor( + if (isOnline) Color.parseColor("#4CAF50") else Color.parseColor("#F44336") + ) + binding?.syncConnectBtn?.text = "Reconnect" + + val lastSync = FirestoreSyncManager.getLastSyncTime(requireContext()) + if (lastSync != null) { + val sdf = SimpleDateFormat("MMM dd, HH:mm:ss", Locale.getDefault()) + binding?.syncLastTime?.text = sdf.format(Date(lastSync)) + } else { + binding?.syncLastTime?.text = "Never" + } + } else { + binding?.syncConnectBtn?.text = "Connect & Sync" + } + } +} 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..362a4bf1100 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 @@ -158,6 +159,22 @@ object DataStore { } fun Context.setKey(path: String, value: T) { + try { + val json = mapper.writeValueAsString(value) + val current = getSharedPrefs().getString(path, null) + if (current == json) return + + getSharedPrefs().edit { + putString(path, json) + } + // Always push as JSON string for consistency in mirror sync + FirestoreSyncManager.pushData(path, json) + } catch (e: Exception) { + logError(e) + } + } + + fun Context.setKeyLocal(path: String, value: T) { try { getSharedPrefs().edit { putString(path, mapper.writeValueAsString(value)) @@ -167,11 +184,17 @@ object DataStore { } } + fun Context.setKeyLocal(folder: String, path: String, value: T) { + setKeyLocal(getFolderName(folder, path), value) + } + 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 } } @@ -192,9 +215,40 @@ object DataStore { inline fun Context.getKey(path: String, defVal: T?): T? { try { val json: String = getSharedPrefs().getString(path, null) ?: return defVal - return json.toKotlinObject() + 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) { + 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.java == String::class.java -> { + // 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.java == Boolean::class.java || T::class.java == java.lang.Boolean::class.java -> { + (json.lowercase() == "true" || json == "1") as T + } + T::class.java == Long::class.java || T::class.java == java.lang.Long::class.java -> { + json.toLongOrNull() as? T ?: defVal + } + T::class.java == Int::class.java || T::class.java == java.lang.Integer::class.java -> { + json.toIntOrNull() as? T ?: defVal + } + else -> defVal + } + Log.d("DataStore", "getKey(Reified) $path fallback: '$fallback'") + fallback + } } catch (e: Exception) { - return null + Log.e("DataStore", "getKey(Reified) $path total fail: ${e.message}") + return defVal } } 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..e80eefa4654 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.DataStore.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_DELETED = "result_resume_watching_deleted" 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" @@ -491,6 +493,13 @@ object DataStoreHelper { } } + fun getAllResumeStateDeletionIds(): List? { + val folder = "$currentAccount/$RESULT_RESUME_WATCHING_DELETED" + return getKeys(folder)?.mapNotNull { + it.removePrefix("$folder/").toIntOrNull() + } + } + private fun getAllResumeStateIdsOld(): List? { val folder = "$currentAccount/$RESULT_RESUME_WATCHING_OLD" return getKeys(folder)?.mapNotNull { @@ -526,7 +535,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,10 +544,12 @@ object DataStoreHelper { episodeId, episode, season, - updateTime ?: System.currentTimeMillis(), + time, isFromDownload ) ) + // Remove tombstone if it exists (Re-vivification) + removeKey("$currentAccount/$RESULT_RESUME_WATCHING_DELETED", parentId.toString()) } private fun removeLastWatchedOld(parentId: Int?) { @@ -548,6 +560,18 @@ object DataStoreHelper { fun removeLastWatched(parentId: Int?) { if (parentId == null) return removeKey("$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString()) + // Set tombstone + setKey("$currentAccount/$RESULT_RESUME_WATCHING_DELETED", parentId.toString(), System.currentTimeMillis()) + } + + fun setLastWatchedDeletionTime(parentId: Int?, time: Long) { + if (parentId == null) return + setKey("$currentAccount/$RESULT_RESUME_WATCHING_DELETED", parentId.toString(), time) + } + + fun getLastWatchedDeletionTime(parentId: Int?): Long? { + if (parentId == null) return null + return getKey("$currentAccount/$RESULT_RESUME_WATCHING_DELETED", parentId.toString(), null) } fun getLastWatched(id: Int?): VideoDownloadHelper.ResumeWatching? { @@ -644,7 +668,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 +745,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) } } 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..0a4e2798118 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt @@ -0,0 +1,673 @@ +package com.lagradost.cloudstream3.utils + +import android.content.Context +import android.util.Log +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions +import com.google.firebase.firestore.DocumentSnapshot +import com.google.firebase.firestore.FieldValue +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.DataStore.getDefaultSharedPrefs +import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs +import com.lagradost.cloudstream3.utils.DataStore.getKeys +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.DataStore.setKeyLocal +import com.lagradost.cloudstream3.plugins.RepositoryManager +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.plugins.PLUGINS_KEY +import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY + +import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import kotlin.math.max +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.AutoDownloadMode +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.utils.AppContextUtils.isNetworkAvailable +import androidx.core.content.edit +import kotlinx.coroutines.* +import java.util.concurrent.atomic.AtomicBoolean +import java.util.Date +import java.text.SimpleDateFormat +import java.util.Locale +import com.lagradost.cloudstream3.plugins.PluginData +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Manages Firebase Firestore synchronization. + * Follows a "Netflix-style" cross-device sync with conflict resolution. + */ +object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { + private const val TAG = "FirestoreSync" + private const val SYNC_COLLECTION = "users" + private const val SYNC_DOCUMENT = "sync_data" + + private var db: FirebaseFirestore? = null + private var userId: String? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val isInitializing = AtomicBoolean(false) + private var isConnected = false + + private val throttleJobs = ConcurrentHashMap() + private val throttleBatch = ConcurrentHashMap() + + private val syncLogs = mutableListOf() + + fun getLogs(): String { + return syncLogs.joinToString("\n") + } + + private fun log(message: String) { + val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + val entry = "[${sdf.format(Date())}] $message" + syncLogs.add(entry) + if (syncLogs.size > 100) syncLogs.removeAt(0) + Log.d(TAG, entry) + } + + // 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" + const val DEFAULT_USER_ID = "mirror_account" // Hardcoded for 100% mirror 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" + + data class SyncConfig( + val apiKey: String, + val projectId: String, + val appId: String + ) + + override fun onStop(owner: androidx.lifecycle.LifecycleOwner) { + super.onStop(owner) + log("App backgrounded/stopped. Triggering sync...") + CommonActivity.activity?.let { pushAllLocalData(it) } + } + + fun isEnabled(context: Context): Boolean { + return context.getKey(FIREBASE_ENABLED, false) ?: false + } + + fun isOnline(): Boolean { + return isConnected && db != null + } + + fun initialize(context: Context) { + // Register lifecycle observer + com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread { + try { + androidx.lifecycle.ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } catch (e: Exception) { + log("Failed to register lifecycle observer: ${e.message}") + } + } + + log("Auto-initializing sync...") + val isNetwork = context.isNetworkAvailable() + log("Network available: $isNetwork") + + val prefs = context.getSharedPrefs() + log("Raw API Key: '${prefs.getString(FIREBASE_API_KEY, null)}'") + log("Raw project: '${prefs.getString(FIREBASE_PROJECT_ID, null)}'") + log("Raw app ID: '${prefs.getString(FIREBASE_APP_ID, null)}'") + val enabled = isEnabled(context) + log("Sync enabled: $enabled") + + if (!enabled) { + log("Sync is disabled in settings.") + return + } + + // Debugging Config Parsing + val rawApiKey = prefs.getString(FIREBASE_API_KEY, "") ?: "" + val rawProjId = prefs.getString(FIREBASE_PROJECT_ID, "") ?: "" + val rawAppId = prefs.getString(FIREBASE_APP_ID, "") ?: "" + + log("Debug - Raw Prefs: API='$rawApiKey', Proj='$rawProjId', App='$rawAppId'") + + val keyFromStore = context.getKey(FIREBASE_API_KEY) + log("Debug - DataStore.getKey: '$keyFromStore'") + + // Manual cleanup as fallback if DataStore fails + fun cleanVal(raw: String): String { + var v = raw.trim() + if (v.startsWith("\"") && v.endsWith("\"") && v.length >= 2) { + v = v.substring(1, v.length - 1) + } + return v + } + + val config = SyncConfig( + apiKey = if (!keyFromStore.isNullOrBlank()) keyFromStore else cleanVal(rawApiKey), + projectId = context.getKey(FIREBASE_PROJECT_ID, "") ?: cleanVal(rawProjId), + appId = context.getKey(FIREBASE_APP_ID, "") ?: cleanVal(rawAppId) + ) + log("Parsed config: API='${config.apiKey}', Proj='${config.projectId}', App='${config.appId}'") + + if (config.apiKey.isBlank() || config.projectId.isBlank() || config.appId.isBlank()) { + log("Sync config is incomplete: API Key=${config.apiKey.isNotBlank()}, project=${config.projectId.isNotBlank()}, app=${config.appId.isNotBlank()}") + return + } + initialize(context, config) + } + + /** + * Initializes Firebase with custom options provided by the user. + */ + fun initialize(context: Context, config: SyncConfig) { + log("Initialize(config) called. Proj=${config.projectId}") + userId = DEFAULT_USER_ID // Set to hardcoded mirror ID + + if (isInitializing.getAndSet(true)) { + log("Initialization already IN PROGRESS (isInitializing=true).") + return + } + + scope.launch { + log("Coroutine launch started...") + try { + val options = FirebaseOptions.Builder() + .setApiKey(config.apiKey) + .setProjectId(config.projectId) + .setApplicationId(config.appId) + .build() + + // Use project ID as app name to avoid collisions + val appName = "sync_${config.projectId.replace(":", "_")}" + val app = try { + FirebaseApp.getInstance(appName) + } catch (e: Exception) { + FirebaseApp.initializeApp(context, options, appName) + } + + db = FirebaseFirestore.getInstance(app) + isConnected = true + log("Firestore instance obtained. UID: $userId") + + // Save config + log("Saving config to DataStore...") + 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) + + // Start initial sync + handleInitialSync(context) + // Start listening for changes (Mirroring) + setupRealtimeListener(context) + + Log.d(TAG, "Firebase initialized successfully") + log("Initialization SUCCESSFUL.") + } catch (e: Throwable) { + Log.e(TAG, "Failed to initialize Firebase: ${e.message}") + log("Initialization EXCEPTION: ${e.javaClass.simpleName}: ${e.message}") + e.printStackTrace() + isConnected = false + } finally { + log("Setting isInitializing to false (finally).") + isInitializing.set(false) + } + } + } + + private fun handleInitialSync(context: Context) { + val currentUserId = userId + val currentDb = db + if (currentUserId == null || currentDb == null) { + log("Cannot handle initial sync: userId or db is null") + return + } + log("Starting initial sync for user: $currentUserId") + + val userDoc = currentDb.collection(SYNC_COLLECTION).document(currentUserId) + + userDoc.get().addOnSuccessListener { document -> + if (document.exists()) { + log("Remote data exists. Applying to local.") + applyRemoteData(context, document) + } else { + log("Remote database is empty. Uploading local data as baseline.") + pushAllLocalData(context) + } + }.addOnFailureListener { e -> + log("Initial sync FAILED: ${e.message}") + }.addOnCompleteListener { + log("Initial sync task completed.") + updateLastSyncTime(context) + } + } + + private fun updateLastSyncTime(context: Context) { + val now = System.currentTimeMillis() + context.setKeyLocal(FIREBASE_LAST_SYNC, now) + } + + fun getLastSyncTime(context: Context): Long? { + return context.getKey(FIREBASE_LAST_SYNC, 0L).let { if (it == 0L) null else it } + } + + private fun setupRealtimeListener(context: Context) { + val currentUserId = userId + val currentDb = db + if (currentUserId == null || currentDb == null) { + Log.e(TAG, "Cannot setup listener: userId and/or db is null") + return + } + + currentDb.collection(SYNC_COLLECTION).document(currentUserId).addSnapshotListener { snapshot, e -> + if (e != null) { + Log.w(TAG, "Listen failed.", e) + return@addSnapshotListener + } + + if (snapshot != null && snapshot.exists()) { + Log.d(TAG, "Current data: ${snapshot.data}") + scope.launch { + applyRemoteData(context, snapshot) + } + } + } + } + + /** + * Pushes specific data to Firestore with a server timestamp. + */ + fun pushData(key: String, data: Any?) { + val currentDb = db ?: return + val currentUserId = userId ?: return + + scope.launch { + try { + val update = hashMapOf( + key to data, + "${key}_updated" to FieldValue.serverTimestamp(), + "last_sync" to FieldValue.serverTimestamp() + ) + + currentDb.collection(SYNC_COLLECTION).document(currentUserId) + .set(update, SetOptions.merge()) + .addOnSuccessListener { + Log.d(TAG, "Successfully pushed $key") + log("Pushed key: $key") + } + .addOnFailureListener { e -> + Log.e(TAG, "Error pushing $key: ${e.message}") + log("FAILED to push $key: ${e.message}") + } + } catch (e: Throwable) { + log("PushData throw: ${e.message}") + } + } + } + + private var debounceJob: Job? = null + + fun pushAllLocalData(context: Context) { + if (isInitializing.get()) { + log("Sync is initializing, skipping immediate push.") + return + } + + debounceJob?.cancel() + debounceJob = scope.launch { + delay(5000) // Debounce for 5 seconds + performPushAllLocalData(context) + } + } + + private suspend fun performPushAllLocalData(context: Context) { + log("Pushing all local data (background)...") + val currentUserId = userId + val currentDb = db + if (currentUserId == null || currentDb == null) { + log("Cannot push all data: userId or db is null") + return + } + + try { + val allData = extractAllLocalData(context) + val update = mutableMapOf() + allData.forEach { (key, value) -> + update[key] = value + update["${key}_updated"] = FieldValue.serverTimestamp() + } + update["last_sync"] = FieldValue.serverTimestamp() + + currentDb.collection(SYNC_COLLECTION).document(currentUserId).set(update, SetOptions.merge()) + .addOnSuccessListener { + log("Successfully pushed all local data.") + updateLastSyncTime(context) + } + .addOnFailureListener { e -> + log("Failed to push all local data: ${e.message}") + } + } catch (e: Throwable) { + log("PushAllLocalData error: ${e.message}") + } + } + + private fun extractAllLocalData(context: Context): Map { + val data = mutableMapOf() + val sensitiveKeys = setOf( + FIREBASE_API_KEY, FIREBASE_PROJECT_ID, + FIREBASE_APP_ID, FIREBASE_ENABLED, + FIREBASE_LAST_SYNC, + "firebase_sync_enabled" // Just in case of legacy names + ) + + // 1. Settings (PreferenceManager's default prefs) + val settingsMap = context.getDefaultSharedPrefs().all.filter { entry -> + !sensitiveKeys.contains(entry.key) + } + data[SETTINGS_SYNC_KEY] = settingsMap.toJson() + + // 2. Repositories + data[REPOSITORIES_KEY] = context.getSharedPrefs().getString(REPOSITORIES_KEY, null) + + // 3. Accounts (DataStore rebuild_preference) + data[ACCOUNTS_KEY] = context.getSharedPrefs().getString(ACCOUNTS_KEY, null) + + // 4. Generic DataStore Keys (Resume Watching, Watch State, etc.) + // This captures everything in the DataStore preferences that we haven't explicitly handled + val dataStoreMap = context.getSharedPrefs().all.filter { (key, value) -> + !sensitiveKeys.contains(key) && + key != REPOSITORIES_KEY && + key != ACCOUNTS_KEY && + key != PLUGINS_KEY && + !key.contains(RESULT_RESUME_WATCHING) && + !key.contains(RESULT_RESUME_WATCHING_DELETED) && + value is String // DataStore saves as JSON Strings + } + data[DATA_STORE_DUMP_KEY] = dataStoreMap.toJson() + + // 5. Home Settings (Search for home related keys in DataStore) + val homeKeys = context.getKeys("home") + val homeData = homeKeys.associateWith { context.getSharedPrefs().all[it] } + data["home_settings"] = homeData.toJson() + + // 6. Plugins (Online ones) + data["plugins_online"] = context.getSharedPrefs().getString(PLUGINS_KEY, null) + + // 7. Resume Watching (CRDT) + val resumeIds = DataStoreHelper.getAllResumeStateIds() ?: emptyList() + val resumeData = resumeIds.mapNotNull { DataStoreHelper.getLastWatched(it) } + data["resume_watching"] = resumeData.toJson() + + val deletedResumeIds = DataStoreHelper.getAllResumeStateDeletionIds() ?: emptyList() + val deletedResumeData = deletedResumeIds.associateWith { DataStoreHelper.getLastWatchedDeletionTime(it) ?: 0L } + data["resume_watching_deleted"] = deletedResumeData.toJson() + + return data + } + + private fun applyRemoteData(context: Context, snapshot: DocumentSnapshot) { + val remoteData = snapshot.data ?: return + + // 1. Apply Settings + (remoteData[SETTINGS_SYNC_KEY] as? String)?.let { json -> + try { + val settingsMap = parseJson>(json) + var hasChanges = false + val prefs = context.getDefaultSharedPrefs() + val editor = prefs.edit() + + settingsMap.forEach { (key, value) -> + val currentVal = prefs.all[key] + if (currentVal != value) { + hasChanges = true + when (value) { + is Boolean -> editor.putBoolean(key, value) + is Int -> editor.putInt(key, value) + is String -> editor.putString(key, value) + is Float -> editor.putFloat(key, value) + is Long -> editor.putLong(key, value) + } + } + } + + if (hasChanges) { + editor.apply() + log("Settings applied (changed).") + MainActivity.reloadHomeEvent(true) + } + } catch (e: Exception) { log("Failed to apply settings: ${e.message}") } + } + + // 2. Apply Generic DataStore Keys (Resume Watching, etc.) + (remoteData[DATA_STORE_DUMP_KEY] as? String)?.let { json -> + try { + val dataStoreMap = parseJson>(json) + val prefs = context.getSharedPrefs() + val editor = prefs.edit() + var hasChanges = false + + dataStoreMap.forEach { (key, value) -> + if (value is String) { + val currentVal = prefs.getString(key, null) + if (currentVal != value) { + editor.putString(key, value) + hasChanges = true + } + } + } + if (hasChanges) { + editor.apply() + log("DataStore dump applied (changed).") + } + } catch (e: Exception) { log("Failed to apply DataStore dump: ${e.message}") } + } + + // 3. Apply Repositories + (remoteData[REPOSITORIES_KEY] as? String)?.let { json -> + try { + val current = context.getSharedPrefs().getString(REPOSITORIES_KEY, null) + if (current != json) { + log("Applying remote repositories (changed)...") + context.getSharedPrefs().edit { + putString(REPOSITORIES_KEY, json) + } + } + } catch (e: Exception) { log("Failed to apply repos: ${e.message}") } + } + + // 4. Apply Accounts + (remoteData[ACCOUNTS_KEY] as? String)?.let { json -> + try { + val current = context.getSharedPrefs().getString(ACCOUNTS_KEY, null) + if (current != json) { + log("Applying remote accounts (changed)...") + context.getSharedPrefs().edit { + putString(ACCOUNTS_KEY, json) + } + MainActivity.reloadAccountEvent(true) + MainActivity.bookmarksUpdatedEvent(true) + } + } catch (e: Exception) { log("Failed to apply accounts: ${e.message}") } + } + + // 5. Apply Home Settings + (remoteData["home_settings"] as? String)?.let { json -> + try { + val homeMap = parseJson>(json) + val prefs = context.getSharedPrefs() + val editor = prefs.edit() + var hasChanges = false + + homeMap.forEach { (key, value) -> + val currentVal = prefs.all[key] + if (currentVal != value) { + hasChanges = true + when (value) { + is Boolean -> editor.putBoolean(key, value) + is Int -> editor.putInt(key, value) + is String -> editor.putString(key, value) + is Float -> editor.putFloat(key, value) + is Long -> editor.putLong(key, value) + } + } + } + + if (hasChanges) { + editor.apply() + log("Home settings applied (changed).") + MainActivity.reloadHomeEvent(true) + } + } catch (e: Exception) { log("Failed to apply home settings: ${e.message}") } + } + + // 6. Apply Plugins with CRDT Strategy + (remoteData["plugins_online"] as? String)?.let { json -> + try { + // Parse lists + val remoteList = parseJson>(json).toList() + val localJson = context.getSharedPrefs().getString(PLUGINS_KEY, "[]") + val localList = try { parseJson>(localJson ?: "[]").toList() } catch(e:Exception) { emptyList() } + + // Merge Maps + val remoteMap = remoteList.associateBy { it.internalName } + val localMap = localList.associateBy { it.internalName } + val allKeys = (remoteMap.keys + localMap.keys).toSet() + + val lastSyncTime = getLastSyncTime(context) ?: 0L + + val mergedList = allKeys.mapNotNull { key -> + val remote = remoteMap[key] + val local = localMap[key] + + when { + remote != null && local != null -> { + // Conflict: Last Write Wins based on addedDate + if (remote.addedDate >= local.addedDate) remote else local + } + remote != null -> { + // only remote knows about it + remote + } + local != null -> { + // only local knows about it + if (local.addedDate > lastSyncTime) { + // New local addition not yet synced + local + } else { + // Old local, missing from remote -> Treat as Remote Deletion (Legacy/Reset) + local.copy(isDeleted = true, addedDate = System.currentTimeMillis()) + } + } + else -> null + } + } + + if (mergedList != localList) { + log("Sync applied (CRDT merge). Total: ${mergedList.size}") + + // Actuate Deletions + mergedList.filter { it.isDeleted }.forEach { p -> + try { + val file = File(p.filePath) + if (file.exists()) { + log("Deleting plugin (Tombstone): ${p.internalName}") + PluginManager.unloadPlugin(p.filePath) + file.delete() + } + } catch(e: Exception) { log("Failed to delete ${p.internalName}: ${e.message}") } + } + + context.getSharedPrefs().edit { + putString(PLUGINS_KEY, mergedList.toJson()) + } + + // Trigger Download for Alive plugins + if (mergedList.any { !it.isDeleted }) { + CommonActivity.activity?.let { act -> + scope.launch { + try { + @Suppress("DEPRECATION_ERROR") + PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( + act, + AutoDownloadMode.All + ) + } catch (e: Exception) { log("Plugin download error: ${e.message}") } + } + } + } + } + } catch (e: Exception) { log("Failed to apply plugins: ${e.message}") } + } + // 7. Apply Resume Watching (CRDT) + val remoteResumeJson = remoteData["resume_watching"] as? String + val remoteDeletedJson = remoteData["resume_watching_deleted"] as? String + + if (remoteResumeJson != null || remoteDeletedJson != null) { + try { + val remoteAlive = if (remoteResumeJson != null) parseJson>(remoteResumeJson) else emptyList() + val remoteDeleted = if (remoteDeletedJson != null) parseJson>(remoteDeletedJson) else emptyMap() + + val localAliveIds = DataStoreHelper.getAllResumeStateIds() ?: emptyList() + val localAliveMap = localAliveIds.mapNotNull { DataStoreHelper.getLastWatched(it) }.associateBy { it.parentId.toString() } + + val localDeletedIds = DataStoreHelper.getAllResumeStateDeletionIds() ?: emptyList() + val localDeletedMap = localDeletedIds.associate { it.toString() to (DataStoreHelper.getLastWatchedDeletionTime(it) ?: 0L) } + + // 1. Merge Deletions (Max Timestamp wins) + val allDelKeys = remoteDeleted.keys + localDeletedMap.keys + val mergedDeleted = allDelKeys.associateWith { key -> + maxOf(remoteDeleted[key] ?: 0L, localDeletedMap[key] ?: 0L) + } + + // 2. Identify Zombies (Local Alive but Merged Deleted is newer) + mergedDeleted.forEach { (id, delTime) -> + val alive = localAliveMap[id] + if (alive != null) { + // If Deletion is NEWER than Alive Update -> KILL + if (delTime >= alive.updateTime) { + log("CRDT: Killing Zombie ResumeWatching $id") + com.lagradost.cloudstream3.CloudStreamApp.removeKey("${DataStoreHelper.currentAccount}/$RESULT_RESUME_WATCHING", id) + // Ensure tombstone is up to date + DataStoreHelper.setLastWatchedDeletionTime(id.toIntOrNull(), delTime) + } else { + // Alive is newer. Re-vivified. Un-delete locally if deleted record exists. + com.lagradost.cloudstream3.CloudStreamApp.removeKey("${DataStoreHelper.currentAccount}/$RESULT_RESUME_WATCHING_DELETED", id) + } + } else { + // Ensure tombstone is present locally + DataStoreHelper.setLastWatchedDeletionTime(id.toIntOrNull(), delTime) + } + } + + // 3. Process Remote Alive + remoteAlive.forEach { remoteItem -> + val id = remoteItem.parentId.toString() + val delTime = mergedDeleted[id] ?: 0L + + // If Remote Alive is OLDER than Deletion -> Ignore (it's dead) + if (remoteItem.updateTime <= delTime) return@forEach + + val localItem = localAliveMap[id] + if (localItem == null) { + // New Item! + log("CRDT: Adding ResumeWatching $id") + DataStoreHelper.setLastWatched(remoteItem.parentId, remoteItem.episodeId, remoteItem.episode, remoteItem.season, remoteItem.isFromDownload, remoteItem.updateTime) + } else { + // Conflict: LWW (Timestamp) + if (remoteItem.updateTime > localItem.updateTime) { + log("CRDT: Updating ResumeWatching $id (Remote Newer)") + DataStoreHelper.setLastWatched(remoteItem.parentId, remoteItem.episodeId, remoteItem.episode, remoteItem.season, remoteItem.isFromDownload, remoteItem.updateTime) + } + } + } + + } catch(e: Exception) { log("Failed to apply resume watching: ${e.message}") } + } + + log("Remote data alignment finished successfully.") + } +} 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/fragment_sync_settings.xml b/app/src/main/res/layout/fragment_sync_settings.xml new file mode 100644 index 00000000000..94c84d16369 --- /dev/null +++ b/app/src/main/res/layout/fragment_sync_settings.xml @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/main_settings.xml b/app/src/main/res/layout/main_settings.xml index ba377455440..2f697cf45e0 100644 --- a/app/src/main/res/layout/main_settings.xml +++ b/app/src/main/res/layout/main_settings.xml @@ -99,6 +99,7 @@ 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/values/strings.xml b/app/src/main/res/values/strings.xml index 4f3a4f5d836..adc0f231deb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,7 @@ + Firebase Sync + firebase_sync_key %1$s Ep %2$d Cast: %s @@ -180,6 +182,7 @@ Search Library Accounts and Security + Firebase Sync Updates and Backup Info Advanced Search 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" /> + + v0GHHdiyZfmlOhR#?3e19H#c%9Csjxd?$7gP4-ss)u gX6E1V+vl6#IjG?|)i;d=J@93ELTh{=ujr&oZ-9;{VE_OC literal 0 HcmV?d00001 diff --git a/gradle.properties b/gradle.properties index 0168ae437bd..9dc17b18202 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,3 +26,6 @@ org.gradle.configuration-cache=true # Compiling with Java 8 is deprecated but we still use it for now android.javaCompile.suppressSourceTargetDeprecationWarning=true + +# Disable path check for non-ASCII characters (e.g. 'Masaüstü') +android.overridePathCheck=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d32aad375ef..ee80e8999b8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,9 @@ fragmentKtx = "1.8.9" fuzzywuzzy = "1.4.0" jacksonModuleKotlin = { strictly = "2.13.1" } # Later versions don't support minSdk <26 (Crashes on Android TV's and FireSticks) json = "20251224" +firebaseBom = "33.7.0" +googleServices = "4.4.2" +json = "20250517" jsoup = "1.21.2" junit = "4.13.2" junitKtx = "1.3.0" @@ -78,6 +81,7 @@ junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "junitKtx" } juniversalchardet = { module = "com.github.albfernandez:juniversalchardet", version.ref = "juniversalchardet" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleKtx" } +lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleKtx" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" } material = { module = "com.google.android.material:material", version.ref = "material" } media3-cast = { module = "androidx.media3:media3-cast", version.ref = "media3" } @@ -110,6 +114,9 @@ tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovi video = { module = "com.google.android.mediahome:video", version.ref = "video" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } zipline = { module = "app.cash.zipline:zipline-android", version.ref = "zipline" } +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } +firebase-firestore = { module = "com.google.firebase:firebase-firestore" } +firebase-analytics = { module = "com.google.firebase:firebase-analytics" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } @@ -120,10 +127,11 @@ dokka = { id = "org.jetbrains.dokka", version.ref = "dokkaGradlePlugin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinGradlePlugin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm" , version.ref = "kotlinGradlePlugin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlinGradlePlugin" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } [bundles] coil = ["coil", "coil-network-okhttp"] -lifecycle = ["lifecycle-livedata-ktx", "lifecycle-viewmodel-ktx"] +lifecycle = ["lifecycle-livedata-ktx", "lifecycle-process", "lifecycle-viewmodel-ktx"] media3 = ["media3-cast", "media3-common", "media3-container", "media3-datasource-cronet", "media3-datasource-okhttp", "media3-exoplayer", "media3-exoplayer-dash", "media3-exoplayer-hls", "media3-session", "media3-ui"] navigation = ["navigation-fragment-ktx", "navigation-ui-ktx"] nextlib = ["nextlib-media3ext", "nextlib-mediainfo"] From 273a1a8359da3a4fb747241b0ea27ad8e4cb049c Mon Sep 17 00:00:00 2001 From: rappc87 Date: Sat, 10 Jan 2026 18:29:20 +0300 Subject: [PATCH 02/13] Refactor applyRemoteData to reduce cyclomatic complexity --- .../utils/FirestoreSyncManager.kt | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt index 0a4e2798118..8094bac04eb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt @@ -411,8 +411,20 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { private fun applyRemoteData(context: Context, snapshot: DocumentSnapshot) { val remoteData = snapshot.data ?: return + val lastSyncTime = getLastSyncTime(context) ?: 0L + + applySettings(context, remoteData) + applyDataStoreDump(context, remoteData) + applyRepositories(context, remoteData) + applyAccounts(context, remoteData) + applyHomeSettings(context, remoteData) + applyPlugins(context, remoteData, lastSyncTime) + applyResumeWatching(context, remoteData) - // 1. Apply Settings + log("Remote data alignment finished successfully.") + } + + private fun applySettings(context: Context, remoteData: Map) { (remoteData[SETTINGS_SYNC_KEY] as? String)?.let { json -> try { val settingsMap = parseJson>(json) @@ -441,8 +453,9 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } } catch (e: Exception) { log("Failed to apply settings: ${e.message}") } } + } - // 2. Apply Generic DataStore Keys (Resume Watching, etc.) + private fun applyDataStoreDump(context: Context, remoteData: Map) { (remoteData[DATA_STORE_DUMP_KEY] as? String)?.let { json -> try { val dataStoreMap = parseJson>(json) @@ -465,8 +478,9 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } } catch (e: Exception) { log("Failed to apply DataStore dump: ${e.message}") } } + } - // 3. Apply Repositories + private fun applyRepositories(context: Context, remoteData: Map) { (remoteData[REPOSITORIES_KEY] as? String)?.let { json -> try { val current = context.getSharedPrefs().getString(REPOSITORIES_KEY, null) @@ -478,8 +492,9 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } } catch (e: Exception) { log("Failed to apply repos: ${e.message}") } } + } - // 4. Apply Accounts + private fun applyAccounts(context: Context, remoteData: Map) { (remoteData[ACCOUNTS_KEY] as? String)?.let { json -> try { val current = context.getSharedPrefs().getString(ACCOUNTS_KEY, null) @@ -493,8 +508,9 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } } catch (e: Exception) { log("Failed to apply accounts: ${e.message}") } } + } - // 5. Apply Home Settings + private fun applyHomeSettings(context: Context, remoteData: Map) { (remoteData["home_settings"] as? String)?.let { json -> try { val homeMap = parseJson>(json) @@ -523,8 +539,9 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } } catch (e: Exception) { log("Failed to apply home settings: ${e.message}") } } + } - // 6. Apply Plugins with CRDT Strategy + private fun applyPlugins(context: Context, remoteData: Map, lastSyncTime: Long) { (remoteData["plugins_online"] as? String)?.let { json -> try { // Parse lists @@ -537,8 +554,6 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { val localMap = localList.associateBy { it.internalName } val allKeys = (remoteMap.keys + localMap.keys).toSet() - val lastSyncTime = getLastSyncTime(context) ?: 0L - val mergedList = allKeys.mapNotNull { key -> val remote = remoteMap[key] val local = localMap[key] @@ -602,7 +617,9 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } } catch (e: Exception) { log("Failed to apply plugins: ${e.message}") } } - // 7. Apply Resume Watching (CRDT) + } + + private fun applyResumeWatching(context: Context, remoteData: Map) { val remoteResumeJson = remoteData["resume_watching"] as? String val remoteDeletedJson = remoteData["resume_watching_deleted"] as? String @@ -667,7 +684,5 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } catch(e: Exception) { log("Failed to apply resume watching: ${e.message}") } } - - log("Remote data alignment finished successfully.") } } From 55caf42be2601f057cbffa5f63f23a64680b0eab Mon Sep 17 00:00:00 2001 From: rappc87 Date: Sat, 10 Jan 2026 18:31:16 +0300 Subject: [PATCH 03/13] Refactor applyRemoteData to reduce cyclomatic complexity --- .../utils/FirestoreSyncManager.kt | 96 +++++++++++-------- 1 file changed, 55 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt index 8094bac04eb..d7dcb4637c2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt @@ -640,49 +640,63 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { maxOf(remoteDeleted[key] ?: 0L, localDeletedMap[key] ?: 0L) } - // 2. Identify Zombies (Local Alive but Merged Deleted is newer) - mergedDeleted.forEach { (id, delTime) -> - val alive = localAliveMap[id] - if (alive != null) { - // If Deletion is NEWER than Alive Update -> KILL - if (delTime >= alive.updateTime) { - log("CRDT: Killing Zombie ResumeWatching $id") - com.lagradost.cloudstream3.CloudStreamApp.removeKey("${DataStoreHelper.currentAccount}/$RESULT_RESUME_WATCHING", id) - // Ensure tombstone is up to date - DataStoreHelper.setLastWatchedDeletionTime(id.toIntOrNull(), delTime) - } else { - // Alive is newer. Re-vivified. Un-delete locally if deleted record exists. - com.lagradost.cloudstream3.CloudStreamApp.removeKey("${DataStoreHelper.currentAccount}/$RESULT_RESUME_WATCHING_DELETED", id) - } - } else { - // Ensure tombstone is present locally - DataStoreHelper.setLastWatchedDeletionTime(id.toIntOrNull(), delTime) - } - } - - // 3. Process Remote Alive - remoteAlive.forEach { remoteItem -> - val id = remoteItem.parentId.toString() - val delTime = mergedDeleted[id] ?: 0L - - // If Remote Alive is OLDER than Deletion -> Ignore (it's dead) - if (remoteItem.updateTime <= delTime) return@forEach - - val localItem = localAliveMap[id] - if (localItem == null) { - // New Item! - log("CRDT: Adding ResumeWatching $id") - DataStoreHelper.setLastWatched(remoteItem.parentId, remoteItem.episodeId, remoteItem.episode, remoteItem.season, remoteItem.isFromDownload, remoteItem.updateTime) - } else { - // Conflict: LWW (Timestamp) - if (remoteItem.updateTime > localItem.updateTime) { - log("CRDT: Updating ResumeWatching $id (Remote Newer)") - DataStoreHelper.setLastWatched(remoteItem.parentId, remoteItem.episodeId, remoteItem.episode, remoteItem.season, remoteItem.isFromDownload, remoteItem.updateTime) - } - } - } + handleResumeZombies(mergedDeleted, localAliveMap) + handleResumeAlive(remoteAlive, mergedDeleted, localAliveMap) } catch(e: Exception) { log("Failed to apply resume watching: ${e.message}") } } } + + private fun handleResumeZombies( + mergedDeleted: Map, + localAliveMap: Map + ) { + // 2. Identify Zombies (Local Alive but Merged Deleted is newer) + mergedDeleted.forEach { (id, delTime) -> + val alive = localAliveMap[id] + if (alive != null) { + // If Deletion is NEWER than Alive Update -> KILL + if (delTime >= alive.updateTime) { + log("CRDT: Killing Zombie ResumeWatching $id") + com.lagradost.cloudstream3.CloudStreamApp.removeKey("${DataStoreHelper.currentAccount}/$com.lagradost.cloudstream3.utils.DataStoreHelper.RESULT_RESUME_WATCHING", id) + // Ensure tombstone is up to date + DataStoreHelper.setLastWatchedDeletionTime(id.toIntOrNull(), delTime) + } else { + // Alive is newer. Re-vivified. Un-delete locally if deleted record exists. + com.lagradost.cloudstream3.CloudStreamApp.removeKey("${DataStoreHelper.currentAccount}/$com.lagradost.cloudstream3.utils.DataStoreHelper.RESULT_RESUME_WATCHING_DELETED", id) + } + } else { + // Ensure tombstone is present locally + DataStoreHelper.setLastWatchedDeletionTime(id.toIntOrNull(), delTime) + } + } + } + + private fun handleResumeAlive( + remoteAlive: List, + mergedDeleted: Map, + localAliveMap: Map + ) { + // 3. Process Remote Alive + remoteAlive.forEach { remoteItem -> + val id = remoteItem.parentId.toString() + val delTime = mergedDeleted[id] ?: 0L + + // If Remote Alive is OLDER than Deletion -> Ignore (it's dead) + if (remoteItem.updateTime <= delTime) return@forEach + + val localItem = localAliveMap[id] + if (localItem == null) { + // New Item! + log("CRDT: Adding ResumeWatching $id") + DataStoreHelper.setLastWatched(remoteItem.parentId, remoteItem.episodeId, remoteItem.episode, remoteItem.season, remoteItem.isFromDownload, remoteItem.updateTime) + } else { + // Conflict: LWW (Timestamp) + if (remoteItem.updateTime > localItem.updateTime) { + log("CRDT: Updating ResumeWatching $id (Remote Newer)") + DataStoreHelper.setLastWatched(remoteItem.parentId, remoteItem.episodeId, remoteItem.episode, remoteItem.season, remoteItem.isFromDownload, remoteItem.updateTime) + } + } + } + } } From 958893f73fd85ab5eed7ce87b21febccc69d289a Mon Sep 17 00:00:00 2001 From: rappc87 Date: Sat, 10 Jan 2026 18:37:17 +0300 Subject: [PATCH 04/13] Fixed the problem --- .../com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt index d7dcb4637c2..eaaee3b3a2c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt @@ -658,12 +658,12 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { // If Deletion is NEWER than Alive Update -> KILL if (delTime >= alive.updateTime) { log("CRDT: Killing Zombie ResumeWatching $id") - com.lagradost.cloudstream3.CloudStreamApp.removeKey("${DataStoreHelper.currentAccount}/$com.lagradost.cloudstream3.utils.DataStoreHelper.RESULT_RESUME_WATCHING", id) + com.lagradost.cloudstream3.CloudStreamApp.removeKey("${DataStoreHelper.currentAccount}/$RESULT_RESUME_WATCHING", id) // Ensure tombstone is up to date DataStoreHelper.setLastWatchedDeletionTime(id.toIntOrNull(), delTime) } else { // Alive is newer. Re-vivified. Un-delete locally if deleted record exists. - com.lagradost.cloudstream3.CloudStreamApp.removeKey("${DataStoreHelper.currentAccount}/$com.lagradost.cloudstream3.utils.DataStoreHelper.RESULT_RESUME_WATCHING_DELETED", id) + com.lagradost.cloudstream3.CloudStreamApp.removeKey("${DataStoreHelper.currentAccount}/$RESULT_RESUME_WATCHING_DELETED", id) } } else { // Ensure tombstone is present locally From bef44ec91590299a1115f0382528878e4b55afed Mon Sep 17 00:00:00 2001 From: rappc87 Date: Sat, 10 Jan 2026 23:14:53 +0300 Subject: [PATCH 05/13] Improved --- .../lagradost/cloudstream3/MainActivity.kt | 2 +- .../cloudstream3/utils/DataStoreHelper.kt | 6 +- .../utils/FirestoreSyncManager.kt | 116 ++++++++++++------ 3 files changed, 86 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 40c99fb622e..cb95270e743 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -1667,7 +1667,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa navController.addOnDestinationChangedListener { _: NavController, navDestination: NavDestination, bundle: Bundle? -> if (FirestoreSyncManager.isEnabled(this@MainActivity)) { - FirestoreSyncManager.pushAllLocalData(this@MainActivity) + FirestoreSyncManager.syncNow(this@MainActivity) } // Intercept search and add a query updateNavBar(navDestination) 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 e80eefa4654..b1393d82598 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -475,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?) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt index eaaee3b3a2c..b5d51e563e7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt @@ -324,6 +324,20 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } } + /** + * Forces an immediate push and pull of all data without debouncing. + */ + fun syncNow(context: Context) { + if (!isEnabled(context) || !isConnected) return + + scope.launch { + // 1. Immediate Pull + handleInitialSync(context) + // 2. Immediate Push + performPushAllLocalData(context) + } + } + private suspend fun performPushAllLocalData(context: Context) { log("Pushing all local data (background)...") val currentUserId = userId @@ -376,7 +390,7 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { // 3. Accounts (DataStore rebuild_preference) data[ACCOUNTS_KEY] = context.getSharedPrefs().getString(ACCOUNTS_KEY, null) - // 4. Generic DataStore Keys (Resume Watching, Watch State, etc.) + // 4. Generic DataStore Keys (Watch State, etc.) // This captures everything in the DataStore preferences that we haven't explicitly handled val dataStoreMap = context.getSharedPrefs().all.filter { (key, value) -> !sensitiveKeys.contains(key) && @@ -385,14 +399,20 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { key != PLUGINS_KEY && !key.contains(RESULT_RESUME_WATCHING) && !key.contains(RESULT_RESUME_WATCHING_DELETED) && + !key.contains("home") && // Exclude home settings from dump + !key.contains("pinned_providers") && // Exclude pinned providers value is String // DataStore saves as JSON Strings } data[DATA_STORE_DUMP_KEY] = dataStoreMap.toJson() - // 5. Home Settings (Search for home related keys in DataStore) - val homeKeys = context.getKeys("home") - val homeData = homeKeys.associateWith { context.getSharedPrefs().all[it] } - data["home_settings"] = homeData.toJson() + // 5. Explicit Individual Keys (Homepage, Pinned, etc.) + // We push these to the root for better visibility and to avoid blob conflicts + val rootIndividualKeys = context.getSharedPrefs().all.filter { (key, _) -> + key.contains("home") || key.contains("pinned_providers") + } + rootIndividualKeys.forEach { (key, value) -> + data[key] = value + } // 6. Plugins (Online ones) data["plugins_online"] = context.getSharedPrefs().getString(PLUGINS_KEY, null) @@ -417,13 +437,60 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { applyDataStoreDump(context, remoteData) applyRepositories(context, remoteData) applyAccounts(context, remoteData) - applyHomeSettings(context, remoteData) + // applyHomeSettings(context, remoteData) // Deprecated: replaced by individual key sync applyPlugins(context, remoteData, lastSyncTime) applyResumeWatching(context, remoteData) + applyIndividualKeys(context, remoteData) + + // Use bookmarksUpdatedEvent as a general "data refreshed" signal for the UI + // HomeViewModel listens to this and reloads both Continue Watching and Bookmarks. + MainActivity.bookmarksUpdatedEvent(true) log("Remote data alignment finished successfully.") } + private fun applyIndividualKeys(context: Context, remoteData: Map) { + val reservedKeys = setOf( + SETTINGS_SYNC_KEY, DATA_STORE_DUMP_KEY, ACCOUNTS_KEY, REPOSITORIES_KEY, + "home_settings", "plugins_online", "resume_watching", "resume_watching_deleted", + "last_sync", FIREBASE_API_KEY, FIREBASE_PROJECT_ID, FIREBASE_APP_ID, FIREBASE_ENABLED, FIREBASE_LAST_SYNC + ) + + val prefs = context.getSharedPrefs() + val editor = prefs.edit() + var hasChanges = false + var providerChanged = false + + remoteData.forEach { (key, value) -> + // Skip reserved keys and timestamp keys + if (reservedKeys.contains(key) || key.endsWith("_updated")) return@forEach + + // Only process String values (DataStore convention) + if (value is String) { + // Check if local value is different + val localValue = prefs.getString(key, null) + if (localValue != value) { + editor.putString(key, value) + hasChanges = true + + // Specific check for homepage/provider related changes + if (key.contains("home") || key.contains("pinned_providers")) { + providerChanged = true + } + + log("Applied individual key: $key") + } + } + } + + if (hasChanges) { + editor.apply() + if (providerChanged) { + MainActivity.reloadHomeEvent(true) + } + } + } + private fun applySettings(context: Context, remoteData: Map) { (remoteData[SETTINGS_SYNC_KEY] as? String)?.let { json -> try { @@ -449,7 +516,9 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { if (hasChanges) { editor.apply() log("Settings applied (changed).") - MainActivity.reloadHomeEvent(true) + // Full reload only if plugin settings might have changed + // (keeping it for safety here but user said only plugin change) + // MainActivity.reloadHomeEvent(true) } } catch (e: Exception) { log("Failed to apply settings: ${e.message}") } } @@ -510,36 +579,13 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } } + // Deprecated: Homepage settings are now synced as individual root keys + // to avoid conflicts with blobs and ensure real-time updates. + /* private fun applyHomeSettings(context: Context, remoteData: Map) { - (remoteData["home_settings"] as? String)?.let { json -> - try { - val homeMap = parseJson>(json) - val prefs = context.getSharedPrefs() - val editor = prefs.edit() - var hasChanges = false - - homeMap.forEach { (key, value) -> - val currentVal = prefs.all[key] - if (currentVal != value) { - hasChanges = true - when (value) { - is Boolean -> editor.putBoolean(key, value) - is Int -> editor.putInt(key, value) - is String -> editor.putString(key, value) - is Float -> editor.putFloat(key, value) - is Long -> editor.putLong(key, value) - } - } - } - - if (hasChanges) { - editor.apply() - log("Home settings applied (changed).") - MainActivity.reloadHomeEvent(true) - } - } catch (e: Exception) { log("Failed to apply home settings: ${e.message}") } - } + ... } + */ private fun applyPlugins(context: Context, remoteData: Map, lastSyncTime: Long) { (remoteData["plugins_online"] as? String)?.let { json -> From 6f4184d867a32adf5fd4664a1af751ba1298d97d Mon Sep 17 00:00:00 2001 From: rappc87 Date: Sat, 10 Jan 2026 23:59:31 +0300 Subject: [PATCH 06/13] One more improve --- .../ui/settings/SyncSettingsFragment.kt | 12 ++--- .../utils/FirestoreSyncManager.kt | 48 ++++++++++++------- 2 files changed, 36 insertions(+), 24 deletions(-) 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 index 856392e398e..c3738823add 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt @@ -38,10 +38,10 @@ class SyncSettingsFragment : BaseFragment( } binding?.syncNowBtn?.setOnClickListener { - showToast("Sync started...") - FirestoreSyncManager.pushAllLocalData(requireContext()) + showToast("Syncing...") + FirestoreSyncManager.pushAllLocalData(requireContext(), immediate = true) // Brief delay to allow sync to happen then update UI - view?.postDelayed({ updateStatusUI() }, 2000) + view?.postDelayed({ updateStatusUI() }, 1000) } binding.syncCopyLogsBtn.setOnClickListener { @@ -83,9 +83,9 @@ class SyncSettingsFragment : BaseFragment( ) FirestoreSyncManager.initialize(requireContext(), config) - showToast("Connecting to Firebase...") - // Delay update to allow initialization to start - view?.postDelayed({ updateStatusUI() }, 3000) + showToast("Initial sync started...") + // Faster update since initial sync is now immediate + view?.postDelayed({ updateStatusUI() }, 1500) } private fun updateStatusUI() { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt index b5d51e563e7..4c34f8211f6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt @@ -201,7 +201,7 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { context.setKey(FIREBASE_ENABLED, true) // Start initial sync - handleInitialSync(context) + handleInitialSync(context, isFullReload = true) // Start listening for changes (Mirroring) setupRealtimeListener(context) @@ -219,24 +219,24 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } } - private fun handleInitialSync(context: Context) { + private fun handleInitialSync(context: Context, isFullReload: Boolean) { val currentUserId = userId val currentDb = db if (currentUserId == null || currentDb == null) { log("Cannot handle initial sync: userId or db is null") return } - log("Starting initial sync for user: $currentUserId") + log("Starting initial sync for user: $currentUserId (FullReload=$isFullReload)") val userDoc = currentDb.collection(SYNC_COLLECTION).document(currentUserId) userDoc.get().addOnSuccessListener { document -> if (document.exists()) { log("Remote data exists. Applying to local.") - applyRemoteData(context, document) + applyRemoteData(context, document, isFullReload = isFullReload) } else { log("Remote database is empty. Uploading local data as baseline.") - pushAllLocalData(context) + pushAllLocalData(context, immediate = true) } }.addOnFailureListener { e -> log("Initial sync FAILED: ${e.message}") @@ -272,7 +272,7 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { if (snapshot != null && snapshot.exists()) { Log.d(TAG, "Current data: ${snapshot.data}") scope.launch { - applyRemoteData(context, snapshot) + applyRemoteData(context, snapshot, isFullReload = false) } } } @@ -311,16 +311,20 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { private var debounceJob: Job? = null - fun pushAllLocalData(context: Context) { + fun pushAllLocalData(context: Context, immediate: Boolean = false) { if (isInitializing.get()) { log("Sync is initializing, skipping immediate push.") return } debounceJob?.cancel() - debounceJob = scope.launch { - delay(5000) // Debounce for 5 seconds - performPushAllLocalData(context) + if (immediate) { + scope.launch { performPushAllLocalData(context) } + } else { + debounceJob = scope.launch { + delay(5000) // Debounce for 5 seconds + performPushAllLocalData(context) + } } } @@ -331,8 +335,8 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { if (!isEnabled(context) || !isConnected) return scope.launch { - // 1. Immediate Pull - handleInitialSync(context) + // 1. Immediate Pull (Differential, no full reload) + handleInitialSync(context, isFullReload = false) // 2. Immediate Push performPushAllLocalData(context) } @@ -429,7 +433,7 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { return data } - private fun applyRemoteData(context: Context, snapshot: DocumentSnapshot) { + private fun applyRemoteData(context: Context, snapshot: DocumentSnapshot, isFullReload: Boolean) { val remoteData = snapshot.data ?: return val lastSyncTime = getLastSyncTime(context) ?: 0L @@ -442,11 +446,17 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { applyResumeWatching(context, remoteData) applyIndividualKeys(context, remoteData) - // Use bookmarksUpdatedEvent as a general "data refreshed" signal for the UI - // HomeViewModel listens to this and reloads both Continue Watching and Bookmarks. + // Multi-event update for full data alignment (only on initial sync or manual setup) + if (isFullReload) { + MainActivity.reloadHomeEvent(true) + MainActivity.reloadLibraryEvent(true) + MainActivity.reloadAccountEvent(true) + } + + // Always signal bookmarks/resume updates for targeted UI refreshes MainActivity.bookmarksUpdatedEvent(true) - log("Remote data alignment finished successfully.") + log("Remote data alignment finished successfully (FullReload=$isFullReload).") } private fun applyIndividualKeys(context: Context, remoteData: Map) { @@ -473,8 +483,10 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { editor.putString(key, value) hasChanges = true - // Specific check for homepage/provider related changes - if (key.contains("home") || key.contains("pinned_providers")) { + // Specific check for homepage provider change (Mirroring) + // We ONLY reload the full home if the selected provider for the CURRENT account changes. + val activeHomeKey = "${DataStoreHelper.currentAccount}/$USER_SELECTED_HOMEPAGE_API" + if (key == activeHomeKey) { providerChanged = true } From 9bb2541247c4622cd9136f0075447503a4a0f61f Mon Sep 17 00:00:00 2001 From: rappc87 Date: Sun, 11 Jan 2026 09:48:15 +0300 Subject: [PATCH 07/13] Home Provider Sync option added --- .../ui/settings/SyncSettingsFragment.kt | 9 +++ .../lagradost/cloudstream3/utils/DataStore.kt | 2 +- .../utils/FirestoreSyncManager.kt | 29 ++++++++- .../res/layout/fragment_sync_settings.xml | 59 +++++++++++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) 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 index c3738823add..0a9bc2d5ff1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt @@ -9,6 +9,7 @@ import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.databinding.FragmentSyncSettingsBinding import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.FirestoreSyncManager import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.txt @@ -72,6 +73,10 @@ class SyncSettingsFragment : BaseFragment( syncProjectId.doAfterTextChanged { checkBtn() } syncAppId.doAfterTextChanged { checkBtn() } checkBtn() + + syncHomepageSwitch.setOnCheckedChangeListener { _, isChecked -> + requireContext().setKey(FirestoreSyncManager.FIREBASE_SYNC_HOMEPAGE_PROVIDER, isChecked) + } } } @@ -106,8 +111,12 @@ class SyncSettingsFragment : BaseFragment( } else { binding?.syncLastTime?.text = "Never" } + + binding?.syncSettingsCard?.isVisible = true + binding?.syncHomepageSwitch?.isChecked = requireContext().getKey(FirestoreSyncManager.FIREBASE_SYNC_HOMEPAGE_PROVIDER, true) ?: true } else { binding?.syncConnectBtn?.text = "Connect & Sync" + binding?.syncSettingsCard?.isVisible = false } } } 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 362a4bf1100..6c368ab66f0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -168,7 +168,7 @@ object DataStore { putString(path, json) } // Always push as JSON string for consistency in mirror sync - FirestoreSyncManager.pushData(path, json) + FirestoreSyncManager.pushData(this, path, json) } catch (e: Exception) { logError(e) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt index 4c34f8211f6..5314adab3cd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt @@ -76,10 +76,20 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { const val FIREBASE_APP_ID = "firebase_app_id" const val FIREBASE_ENABLED = "firebase_sync_enabled" const val FIREBASE_LAST_SYNC = "firebase_last_sync" + const val FIREBASE_SYNC_HOMEPAGE_PROVIDER = "firebase_sync_homepage_provider" const val DEFAULT_USER_ID = "mirror_account" // Hardcoded for 100% mirror 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" + + private fun isHomepageKey(key: String): Boolean { + // Matches "0/home_api_used", "1/home_api_used", etc. + return key.endsWith("/$USER_SELECTED_HOMEPAGE_API") + } + + private fun shouldSyncHomepage(context: Context): Boolean { + return context.getKey(FIREBASE_SYNC_HOMEPAGE_PROVIDER, true) ?: true + } data class SyncConfig( val apiKey: String, @@ -309,6 +319,15 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } } + // Overload for Context-aware push that respects homepage sync setting + fun pushData(context: Context, key: String, data: Any?) { + if (isHomepageKey(key) && !shouldSyncHomepage(context)) { + log("Skipping push of homepage key $key (Sync disabled)") + return + } + pushData(key, data) + } + private var debounceJob: Job? = null fun pushAllLocalData(context: Context, immediate: Boolean = false) { @@ -409,10 +428,10 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } data[DATA_STORE_DUMP_KEY] = dataStoreMap.toJson() - // 5. Explicit Individual Keys (Homepage, Pinned, etc.) // We push these to the root for better visibility and to avoid blob conflicts val rootIndividualKeys = context.getSharedPrefs().all.filter { (key, _) -> - key.contains("home") || key.contains("pinned_providers") + (key.contains("home") || key.contains("pinned_providers")) && + (!isHomepageKey(key) || shouldSyncHomepage(context)) } rootIndividualKeys.forEach { (key, value) -> data[key] = value @@ -480,6 +499,12 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { // Check if local value is different val localValue = prefs.getString(key, null) if (localValue != value) { + // Skip homepage key if sync is disabled + if (isHomepageKey(key) && !shouldSyncHomepage(context)) { + log("Skipping apply of remote homepage key $key (Sync disabled)") + return@forEach + } + editor.putString(key, value) hasChanges = true diff --git a/app/src/main/res/layout/fragment_sync_settings.xml b/app/src/main/res/layout/fragment_sync_settings.xml index 94c84d16369..303c86f21f0 100644 --- a/app/src/main/res/layout/fragment_sync_settings.xml +++ b/app/src/main/res/layout/fragment_sync_settings.xml @@ -107,6 +107,65 @@ + + + + + + + + + + + + + + + + + + Date: Sun, 11 Jan 2026 11:57:35 +0300 Subject: [PATCH 08/13] More SYNC Options --- .../ui/settings/SyncSettingsFragment.kt | 59 ++++- .../utils/FirestoreSyncManager.kt | 204 ++++++++++++++---- .../res/layout/fragment_sync_settings.xml | 195 ++++++++++++++--- app/src/main/res/layout/sync_item_row.xml | 39 ++++ 4 files changed, 422 insertions(+), 75 deletions(-) create mode 100644 app/src/main/res/layout/sync_item_row.xml 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 index 0a9bc2d5ff1..0e42b89c45c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt @@ -74,9 +74,32 @@ class SyncSettingsFragment : BaseFragment( syncAppId.doAfterTextChanged { checkBtn() } checkBtn() - syncHomepageSwitch.setOnCheckedChangeListener { _, isChecked -> - requireContext().setKey(FirestoreSyncManager.FIREBASE_SYNC_HOMEPAGE_PROVIDER, isChecked) - } + // Bind granular toggles + setupGranularToggle(syncAppearanceLayout, FirestoreSyncManager.SYNC_SETTING_APPEARANCE, "Appearance", "Sync theme, colors, and layout preferences.") + setupGranularToggle(syncPlayerLayout, FirestoreSyncManager.SYNC_SETTING_PLAYER, "Player Settings", "Sync subtitle styles, player gestures, and video quality.") + setupGranularToggle(syncDownloadsLayout, FirestoreSyncManager.SYNC_SETTING_DOWNLOADS, "Downloads", "Sync download paths and parallel download limits.") + setupGranularToggle(syncGeneralLayout, FirestoreSyncManager.SYNC_SETTING_GENERAL, "General Settings", "Sync miscellaneous app-wide preferences.") + + setupGranularToggle(syncAccountsLayout, FirestoreSyncManager.SYNC_SETTING_ACCOUNTS, "User Profiles", "Sync profile names, avatars, and linked accounts.") + setupGranularToggle(syncBookmarksLayout, FirestoreSyncManager.SYNC_SETTING_BOOKMARKS, "Bookmarks", "Sync your watchlist and favorite items.") + setupGranularToggle(syncResumeWatchingLayout, FirestoreSyncManager.SYNC_SETTING_RESUME_WATCHING, "Watch Progress", "Sync where you left off on every movie/episode.") + + setupGranularToggle(syncRepositoriesLayout, FirestoreSyncManager.SYNC_SETTING_REPOSITORIES, "Source Repositories", "Sync the list of added plugin repositories.") + setupGranularToggle(syncPluginsLayout, FirestoreSyncManager.SYNC_SETTING_PLUGINS, "Installed Plugins", "Sync which online plugins are installed.") + + setupGranularToggle(syncHomepageLayout, FirestoreSyncManager.SYNC_SETTING_HOMEPAGE_API, "Home Provider", "Sync which homepage source is currently active.") + setupGranularToggle(syncPinnedLayout, FirestoreSyncManager.SYNC_SETTING_PINNED_PROVIDERS, "Pinned Providers", "Sync your pinned providers on the home screen.") + } + } + + private fun setupGranularToggle(row: com.lagradost.cloudstream3.databinding.SyncItemRowBinding, key: String, title: String, desc: String) { + row.syncItemTitle.text = title + row.syncItemDesc.text = desc + val current = requireContext().getKey(key, true) ?: true + row.syncItemSwitch.isChecked = current + + row.syncItemSwitch.setOnCheckedChangeListener { _, isChecked -> + requireContext().setKey(key, isChecked) } } @@ -89,7 +112,6 @@ class SyncSettingsFragment : BaseFragment( FirestoreSyncManager.initialize(requireContext(), config) showToast("Initial sync started...") - // Faster update since initial sync is now immediate view?.postDelayed({ updateStatusUI() }, 1500) } @@ -112,11 +134,34 @@ class SyncSettingsFragment : BaseFragment( binding?.syncLastTime?.text = "Never" } - binding?.syncSettingsCard?.isVisible = true - binding?.syncHomepageSwitch?.isChecked = requireContext().getKey(FirestoreSyncManager.FIREBASE_SYNC_HOMEPAGE_PROVIDER, true) ?: true + binding?.syncAppSettingsCard?.isVisible = true + binding?.syncLibraryCard?.isVisible = true + binding?.syncExtensionsCard?.isVisible = true + binding?.syncInterfaceCard?.isVisible = true + + // Re-sync switch states visually + binding?.apply { + syncAppearanceLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_APPEARANCE, true) ?: true + syncPlayerLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_PLAYER, true) ?: true + syncDownloadsLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_DOWNLOADS, true) ?: true + syncGeneralLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_GENERAL, true) ?: true + + syncAccountsLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_ACCOUNTS, true) ?: true + syncBookmarksLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_BOOKMARKS, true) ?: true + syncResumeWatchingLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_RESUME_WATCHING, true) ?: true + + syncRepositoriesLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_REPOSITORIES, true) ?: true + syncPluginsLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_PLUGINS, true) ?: true + + syncHomepageLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_HOMEPAGE_API, true) ?: true + syncPinnedLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_PINNED_PROVIDERS, true) ?: true + } } else { binding?.syncConnectBtn?.text = "Connect & Sync" - binding?.syncSettingsCard?.isVisible = false + binding?.syncAppSettingsCard?.isVisible = false + binding?.syncLibraryCard?.isVisible = false + binding?.syncExtensionsCard?.isVisible = false + binding?.syncInterfaceCard?.isVisible = false } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt index 5314adab3cd..6f475573ae7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt @@ -81,6 +81,27 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { 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" + + private fun isSyncControlKey(key: String): Boolean { + return key.startsWith("sync_setting_") + } + + private fun shouldSync(context: Context, controlKey: String): Boolean { + return context.getKey(controlKey, true) ?: true + } private fun isHomepageKey(key: String): Boolean { // Matches "0/home_api_used", "1/home_api_used", etc. @@ -88,7 +109,7 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } private fun shouldSyncHomepage(context: Context): Boolean { - return context.getKey(FIREBASE_SYNC_HOMEPAGE_PROVIDER, true) ?: true + return shouldSync(context, SYNC_SETTING_HOMEPAGE_API) } data class SyncConfig( @@ -319,10 +340,26 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } } - // Overload for Context-aware push that respects homepage sync setting + // Overload for Context-aware push that respects granular sync settings fun pushData(context: Context, key: String, data: Any?) { - if (isHomepageKey(key) && !shouldSyncHomepage(context)) { - log("Skipping push of homepage key $key (Sync disabled)") + if (isSyncControlKey(key)) { + pushData(key, data) + return + } + + val shouldSync = when { + key == ACCOUNTS_KEY -> shouldSync(context, SYNC_SETTING_ACCOUNTS) + key == REPOSITORIES_KEY -> shouldSync(context, SYNC_SETTING_REPOSITORIES) + key == PLUGINS_KEY || key == "plugins_online" -> shouldSync(context, SYNC_SETTING_PLUGINS) + key == "resume_watching" || key == "resume_watching_deleted" -> shouldSync(context, SYNC_SETTING_RESUME_WATCHING) + key.contains("home") || key.contains(USER_SELECTED_HOMEPAGE_API) -> shouldSync(context, SYNC_SETTING_HOMEPAGE_API) + key.contains("pinned_providers") -> shouldSync(context, SYNC_SETTING_PINNED_PROVIDERS) + key == SETTINGS_SYNC_KEY || key == DATA_STORE_DUMP_KEY -> true // These are filtered inside extraction + else -> true + } + + if (!shouldSync) { + log("Skipping push of key $key (Sync disabled by granular setting)") return } pushData(key, data) @@ -401,53 +438,82 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { "firebase_sync_enabled" // Just in case of legacy names ) + // Always include sync control settings + val syncControlKeys = context.getSharedPrefs().all.filter { (key, _) -> isSyncControlKey(key) } + syncControlKeys.forEach { (key, value) -> data[key] = value } + // 1. Settings (PreferenceManager's default prefs) + val syncAppearance = shouldSync(context, SYNC_SETTING_APPEARANCE) + val syncPlayer = shouldSync(context, SYNC_SETTING_PLAYER) + val syncDownloads = shouldSync(context, SYNC_SETTING_DOWNLOADS) + val syncGeneral = shouldSync(context, SYNC_SETTING_GENERAL) + val settingsMap = context.getDefaultSharedPrefs().all.filter { entry -> - !sensitiveKeys.contains(entry.key) + if (sensitiveKeys.contains(entry.key)) return@filter false + + val key = entry.key + when { + key.contains("theme") || key.contains("color") || key.contains("layout") -> syncAppearance + key.contains("player") || key.contains("subtitle") || key.contains("gesture") -> syncPlayer + key.contains("download") -> syncDownloads + else -> syncGeneral + } } data[SETTINGS_SYNC_KEY] = settingsMap.toJson() // 2. Repositories - data[REPOSITORIES_KEY] = context.getSharedPrefs().getString(REPOSITORIES_KEY, null) + if (shouldSync(context, SYNC_SETTING_REPOSITORIES)) { + data[REPOSITORIES_KEY] = context.getSharedPrefs().getString(REPOSITORIES_KEY, null) + } // 3. Accounts (DataStore rebuild_preference) - data[ACCOUNTS_KEY] = context.getSharedPrefs().getString(ACCOUNTS_KEY, null) + if (shouldSync(context, SYNC_SETTING_ACCOUNTS)) { + data[ACCOUNTS_KEY] = context.getSharedPrefs().getString(ACCOUNTS_KEY, null) + } - // 4. Generic DataStore Keys (Watch State, etc.) - // This captures everything in the DataStore preferences that we haven't explicitly handled + // 4. Generic DataStore Keys (Bookmarks, etc.) + val syncBookmarks = shouldSync(context, SYNC_SETTING_BOOKMARKS) val dataStoreMap = context.getSharedPrefs().all.filter { (key, value) -> - !sensitiveKeys.contains(key) && - key != REPOSITORIES_KEY && - key != ACCOUNTS_KEY && - key != PLUGINS_KEY && - !key.contains(RESULT_RESUME_WATCHING) && - !key.contains(RESULT_RESUME_WATCHING_DELETED) && - !key.contains("home") && // Exclude home settings from dump - !key.contains("pinned_providers") && // Exclude pinned providers - value is String // DataStore saves as JSON Strings + if (sensitiveKeys.contains(key) || isSyncControlKey(key)) return@filter false + + val isIgnored = key == REPOSITORIES_KEY || + key == ACCOUNTS_KEY || + key == PLUGINS_KEY || + key.contains(RESULT_RESUME_WATCHING) || + key.contains(RESULT_RESUME_WATCHING_DELETED) || + key.contains("home") || + key.contains("pinned_providers") + + (!isIgnored && syncBookmarks && value is String) } data[DATA_STORE_DUMP_KEY] = dataStoreMap.toJson() - // We push these to the root for better visibility and to avoid blob conflicts + // 5. Interface & Pinned + val syncHome = shouldSync(context, SYNC_SETTING_HOMEPAGE_API) + val syncPinned = shouldSync(context, SYNC_SETTING_PINNED_PROVIDERS) + val rootIndividualKeys = context.getSharedPrefs().all.filter { (key, _) -> - (key.contains("home") || key.contains("pinned_providers")) && - (!isHomepageKey(key) || shouldSyncHomepage(context)) + (key.contains("home") && syncHome) || (key.contains("pinned_providers") && syncPinned) } rootIndividualKeys.forEach { (key, value) -> data[key] = value } // 6. Plugins (Online ones) - data["plugins_online"] = context.getSharedPrefs().getString(PLUGINS_KEY, null) + if (shouldSync(context, SYNC_SETTING_PLUGINS)) { + data["plugins_online"] = context.getSharedPrefs().getString(PLUGINS_KEY, null) + } // 7. Resume Watching (CRDT) - val resumeIds = DataStoreHelper.getAllResumeStateIds() ?: emptyList() - val resumeData = resumeIds.mapNotNull { DataStoreHelper.getLastWatched(it) } - data["resume_watching"] = resumeData.toJson() - - val deletedResumeIds = DataStoreHelper.getAllResumeStateDeletionIds() ?: emptyList() - val deletedResumeData = deletedResumeIds.associateWith { DataStoreHelper.getLastWatchedDeletionTime(it) ?: 0L } - data["resume_watching_deleted"] = deletedResumeData.toJson() + if (shouldSync(context, SYNC_SETTING_RESUME_WATCHING)) { + val resumeIds = DataStoreHelper.getAllResumeStateIds() ?: emptyList() + val resumeData = resumeIds.mapNotNull { DataStoreHelper.getLastWatched(it) } + data["resume_watching"] = resumeData.toJson() + + val deletedResumeIds = DataStoreHelper.getAllResumeStateDeletionIds() ?: emptyList() + val deletedResumeData = deletedResumeIds.associateWith { DataStoreHelper.getLastWatchedDeletionTime(it) ?: 0L } + data["resume_watching_deleted"] = deletedResumeData.toJson() + } return data } @@ -456,14 +522,36 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { val remoteData = snapshot.data ?: return val lastSyncTime = getLastSyncTime(context) ?: 0L - applySettings(context, remoteData) - applyDataStoreDump(context, remoteData) - applyRepositories(context, remoteData) - applyAccounts(context, remoteData) - // applyHomeSettings(context, remoteData) // Deprecated: replaced by individual key sync - applyPlugins(context, remoteData, lastSyncTime) - applyResumeWatching(context, remoteData) - applyIndividualKeys(context, remoteData) + // Priority 1: Apply sync control settings first + applySyncControlSettings(context, remoteData) + + // Priority 2: Conditionally apply other data + if (shouldSync(context, SYNC_SETTING_APPEARANCE) || + shouldSync(context, SYNC_SETTING_PLAYER) || + shouldSync(context, SYNC_SETTING_DOWNLOADS) || + shouldSync(context, SYNC_SETTING_GENERAL)) { + applySettings(context, remoteData) + } + + applyDataStoreDump(context, remoteData) // This now filters based on local SYNC_SETTING_BOOKMARKS + + if (shouldSync(context, SYNC_SETTING_REPOSITORIES)) { + applyRepositories(context, remoteData) + } + + if (shouldSync(context, SYNC_SETTING_ACCOUNTS)) { + applyAccounts(context, remoteData) + } + + if (shouldSync(context, SYNC_SETTING_PLUGINS)) { + applyPlugins(context, remoteData, lastSyncTime) + } + + if (shouldSync(context, SYNC_SETTING_RESUME_WATCHING)) { + applyResumeWatching(context, remoteData) + } + + applyIndividualKeys(context, remoteData) // Internal logic handles SYNC_SETTING_HOMEPAGE_API/PINNED // Multi-event update for full data alignment (only on initial sync or manual setup) if (isFullReload) { @@ -478,6 +566,22 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { log("Remote data alignment finished successfully (FullReload=$isFullReload).") } + private fun applySyncControlSettings(context: Context, remoteData: Map) { + val prefs = context.getSharedPrefs() + val editor = prefs.edit() + var changed = false + remoteData.forEach { (key, value) -> + if (isSyncControlKey(key) && value is Boolean) { + val current = prefs.getBoolean(key, true) + if (current != value) { + editor.putBoolean(key, value) + changed = true + } + } + } + if (changed) editor.apply() + } + private fun applyIndividualKeys(context: Context, remoteData: Map) { val reservedKeys = setOf( SETTINGS_SYNC_KEY, DATA_STORE_DUMP_KEY, ACCOUNTS_KEY, REPOSITORIES_KEY, @@ -504,6 +608,11 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { log("Skipping apply of remote homepage key $key (Sync disabled)") return@forEach } + + if (key.contains("pinned_providers") && !shouldSync(context, SYNC_SETTING_PINNED_PROVIDERS)) { + log("Skipping apply of remote pinned provider key $key (Sync disabled)") + return@forEach + } editor.putString(key, value) hasChanges = true @@ -541,7 +650,20 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { if (currentVal != value) { hasChanges = true when (value) { - is Boolean -> editor.putBoolean(key, value) + is Boolean -> { + val syncAppearance = shouldSync(context, SYNC_SETTING_APPEARANCE) + val syncPlayer = shouldSync(context, SYNC_SETTING_PLAYER) + val syncDownloads = shouldSync(context, SYNC_SETTING_DOWNLOADS) + val syncGeneral = shouldSync(context, SYNC_SETTING_GENERAL) + + val shouldApply = when { + key.contains("theme") || key.contains("color") || key.contains("layout") -> syncAppearance + key.contains("player") || key.contains("subtitle") || key.contains("gesture") -> syncPlayer + key.contains("download") -> syncDownloads + else -> syncGeneral + } + if (shouldApply) editor.putBoolean(key, value) + } is Int -> editor.putInt(key, value) is String -> editor.putString(key, value) is Float -> editor.putFloat(key, value) @@ -573,8 +695,10 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { if (value is String) { val currentVal = prefs.getString(key, null) if (currentVal != value) { - editor.putString(key, value) - hasChanges = true + if (shouldSync(context, SYNC_SETTING_BOOKMARKS)) { + editor.putString(key, value) + hasChanges = true + } } } } diff --git a/app/src/main/res/layout/fragment_sync_settings.xml b/app/src/main/res/layout/fragment_sync_settings.xml index 303c86f21f0..9808e216afb 100644 --- a/app/src/main/res/layout/fragment_sync_settings.xml +++ b/app/src/main/res/layout/fragment_sync_settings.xml @@ -108,14 +108,14 @@ - + @@ -129,39 +129,178 @@ + android:layout_marginBottom="12dp"/> - + + + + + + + + + + + + + + + + + + + + - - - - + android:text="My Library & Data" + android:textColor="?attr/textColor" + android:textSize="18sp" + android:textStyle="bold" + android:layout_marginBottom="12dp"/> + + + + + + + + + + + + + + + + + + + android:textSize="18sp" + android:textStyle="bold" + android:layout_marginBottom="12dp"/> + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/sync_item_row.xml b/app/src/main/res/layout/sync_item_row.xml new file mode 100644 index 00000000000..a6bfec43961 --- /dev/null +++ b/app/src/main/res/layout/sync_item_row.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + From a0068869ef878250c3cff259f9edca790d77973f Mon Sep 17 00:00:00 2001 From: rappc87 Date: Sat, 7 Feb 2026 16:23:17 +0300 Subject: [PATCH 09/13] Fork SYNCED --- gradle.properties | 10 ++++++++++ gradle/libs.versions.toml | 3 +-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 9dc17b18202..10d726d7045 100644 --- a/gradle.properties +++ b/gradle.properties @@ -29,3 +29,13 @@ android.javaCompile.suppressSourceTargetDeprecationWarning=true # Disable path check for non-ASCII characters (e.g. 'Masaüstü') android.overridePathCheck=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ee80e8999b8..513377fa101 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ # https://docs.gradle.org/current/userguide/dependency_versions.html#sec:strict-version [versions] activityKtx = "1.11.0" -androidGradlePlugin = "8.13.2" +androidGradlePlugin = "9.0.0" appcompat = "1.7.1" biometric = "1.4.0-alpha04" buildkonfigGradlePlugin = "0.17.1" @@ -20,7 +20,6 @@ jacksonModuleKotlin = { strictly = "2.13.1" } # Later versions don't support min json = "20251224" firebaseBom = "33.7.0" googleServices = "4.4.2" -json = "20250517" jsoup = "1.21.2" junit = "4.13.2" junitKtx = "1.3.0" From ac712422231832a5f321b1f83b67be18039262c8 Mon Sep 17 00:00:00 2001 From: rappc87 Date: Sun, 8 Feb 2026 18:04:29 +0300 Subject: [PATCH 10/13] UPDATED AS REQUEST --- app/build.gradle.kts | 7 +- .../lagradost/cloudstream3/AcraApplication.kt | 6 +- .../lagradost/cloudstream3/CloudStreamApp.kt | 11 +- .../lagradost/cloudstream3/MainActivity.kt | 4 +- .../cloudstream3/plugins/PluginManager.kt | 8 +- .../syncproviders/providers/AniListApi.kt | 4 +- .../syncproviders/providers/MALApi.kt | 2 +- .../cloudstream3/ui/ControllerActivity.kt | 4 +- .../ui/download/DownloadFragment.kt | 7 +- .../ui/download/DownloadViewModel.kt | 10 +- .../cloudstream3/ui/player/CS3IPlayer.kt | 4 +- .../ui/player/CustomSubripParser.kt | 12 +- .../ui/result/ResultViewModel2.kt | 13 +- .../ui/settings/SyncSettingsFragment.kt | 298 +++- .../subtitles/ChromecastSubtitlesFragment.kt | 2 +- .../ui/subtitles/SubtitlesFragment.kt | 2 +- .../cloudstream3/utils/BackupUtils.kt | 10 +- .../lagradost/cloudstream3/utils/DataStore.kt | 268 ++-- .../cloudstream3/utils/DataStoreHelper.kt | 2 +- .../utils/DownloadFileWorkManager.kt | 4 +- .../utils/FirestoreSyncManager.kt | 1234 ++++++++--------- .../cloudstream3/utils/TvChannelUtils.kt | 6 +- .../utils/VideoDownloadManager.kt | 4 +- .../res/layout/fragment_sync_settings.xml | 264 +++- app/src/main/res/values/colors.xml | 1 + gradle.properties | 11 +- gradle/libs.versions.toml | 3 +- library/build.gradle.kts | 4 +- 28 files changed, 1234 insertions(+), 971 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7dd05622f0b..cccb15e1e25 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,8 +7,8 @@ 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 } @@ -222,6 +222,7 @@ dependencies { // Firebase implementation(platform(libs.firebase.bom)) implementation(libs.firebase.firestore) + implementation(libs.firebase.auth) implementation(libs.firebase.analytics) configurations.all { @@ -282,6 +283,7 @@ tasks.withType { } } +/* dokka { moduleName = "App" dokkaSourceSets { @@ -300,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..0383b10bdb5 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.DataStore +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 diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index cb95270e743..f9add3f9778 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -156,8 +156,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 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 1e16807b96e..7c8e1e4da8d 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 @@ -798,7 +799,7 @@ object PluginManager { val data = PluginData( internalName, pluginUrl, - true, + false, // Mark as local so it updates PLUGINS_KEY_LOCAL immediately newFile.absolutePath, PLUGIN_VERSION_NOT_SET, System.currentTimeMillis() @@ -828,7 +829,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 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/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/download/DownloadFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt index 3bd424640dd..158ed8fd7de 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.DataStore 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 @@ -248,7 +249,7 @@ class DownloadFragment : BaseFragment( DOWNLOAD_ACTION_GO_TO_CHILD -> { if (click.data.type.isEpisodeBased()) { val folder = - getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString()) + DataStore.getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString()) activity?.navigate( R.id.action_navigation_downloads_to_navigation_download_child, DownloadChildFragment.newInstance(click.data.name, folder) @@ -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..4496eb7cb43 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.DataStore 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 @@ -202,7 +202,7 @@ class DownloadViewModel : ViewModel() { val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey( DOWNLOAD_EPISODE_CACHE, - getFolderName(it.id.toString(), it.id.toString()) + DataStore.getFolderName(it.id.toString(), it.id.toString()) ) VisualDownloadCached.Header( @@ -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/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..507b518a127 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,6 +1,7 @@ package com.lagradost.cloudstream3.ui.result import android.app.Activity +import com.lagradost.cloudstream3.utils.DataStore import android.content.* import android.util.Log import android.widget.Toast @@ -57,9 +58,7 @@ 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.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllBookmarkedData @@ -1470,8 +1469,8 @@ class ResultViewModel2 : ViewModel() { val watchStateString = DataStore.mapper.writeValueAsString(watchState) episodeIds.forEach { if (getVideoWatchState(it.toInt()) != watchState) { - editor.setKeyRaw( - getFolderName("$currentAccount/$VIDEO_WATCH_STATE", it), + editor.setKeyRaw( + DataStore.getFolderName("$currentAccount/$VIDEO_WATCH_STATE", it), watchStateString ) } @@ -1725,7 +1724,7 @@ class ResultViewModel2 : ViewModel() { } ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE -> ioSafe { - val editor = context?.let { it1 -> editor(it1, false) } + val editor = context?.let { it1 -> DataStore.editor(it1, false) } if (editor != null) { val (clickSeason, clickEpisode) = click.data.let { @@ -2844,4 +2843,4 @@ class ResultViewModel2 : ViewModel() { } } } -} \ No newline at end of file +} 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 index 0e42b89c45c..641407d228f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt @@ -1,18 +1,25 @@ 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.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.FirestoreSyncManager import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper 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 @@ -24,25 +31,31 @@ class SyncSettingsFragment : BaseFragment( // No special layout fixes needed currently } + override fun onResume() { + super.onResume() + updateUI() + } + override fun onBindingCreated(binding: FragmentSyncSettingsBinding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) - binding?.syncToolbar?.setNavigationOnClickListener { + binding.syncToolbar.setNavigationOnClickListener { activity?.onBackPressedDispatcher?.onBackPressed() } - setupInputs() - updateStatusUI() + setupDatabaseConfigInputs(binding) + setupGranularToggles(binding) + setupAuthActions(binding) + setupPluginActions(binding) - binding?.syncConnectBtn?.setOnClickListener { - connect() + binding.syncConnectBtn.setOnClickListener { + connect(binding) } - binding?.syncNowBtn?.setOnClickListener { + binding.syncNowBtn.setOnClickListener { showToast("Syncing...") - FirestoreSyncManager.pushAllLocalData(requireContext(), immediate = true) - // Brief delay to allow sync to happen then update UI - view?.postDelayed({ updateStatusUI() }, 1000) + FirestoreSyncManager.syncNow(requireContext()) + view?.postDelayed({ updateUI() }, 1000) } binding.syncCopyLogsBtn.setOnClickListener { @@ -54,14 +67,33 @@ class SyncSettingsFragment : BaseFragment( showToast("Logs copied to clipboard") } } + + // 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 isEnabled = FirestoreSyncManager.isEnabled(requireContext()) + binding.syncConfigContainer.isVisible = !isEnabled + if (!isEnabled) { + binding.syncConfigHeader.getChildAt(1).rotation = 180f + } + + updateUI() } - private fun setupInputs() { + private fun setupDatabaseConfigInputs(binding: FragmentSyncSettingsBinding) { val context = requireContext() - binding?.apply { - syncApiKey.setText(context.getKey(FirestoreSyncManager.FIREBASE_API_KEY, "")) - syncProjectId.setText(context.getKey(FirestoreSyncManager.FIREBASE_PROJECT_ID, "")) - syncAppId.setText(context.getKey(FirestoreSyncManager.FIREBASE_APP_ID, "")) + 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 && @@ -73,8 +105,62 @@ class SyncSettingsFragment : BaseFragment( syncProjectId.doAfterTextChanged { checkBtn() } syncAppId.doAfterTextChanged { checkBtn() } checkBtn() + } + } - // Bind granular toggles + private fun setupAuthActions(binding: FragmentSyncSettingsBinding) { + binding.syncLoginRegisterBtn.setOnClickListener { + val email = binding.syncEmailInput.text?.toString()?.trim() ?: "" + val pass = binding.syncPasswordInput.text?.toString()?.trim() ?: "" + + if (email.isBlank() || pass.length < 6) { + showToast("Please enter email and password (min 6 chars).", 1) + return@setOnClickListener + } + + binding.syncLoginRegisterBtn.isEnabled = false + binding.syncLoginRegisterBtn.text = "Authenticating..." + + FirestoreSyncManager.loginOrRegister(email, pass) { success, msg -> + main { + binding.syncLoginRegisterBtn.isEnabled = true + binding.syncLoginRegisterBtn.text = "Login / Register" + + if (success) { + showToast("Authenticated successfully!", 0) + updateUI() + } else { + showToast("Auth failed: $msg", 1) + } + } + } + } + + binding.syncLogoutBtn.setOnClickListener { + FirestoreSyncManager.logout(requireContext()) + updateUI() + } + } + + private fun setupPluginActions(binding: FragmentSyncSettingsBinding) { + binding.syncInstallPluginsBtn.setOnClickListener { + showToast("Installing all pending plugins...") + ioSafe { + FirestoreSyncManager.installAllPending(requireActivity()) + main { updateUI() } + } + } + + binding.syncIgnorePluginsBtn.setOnClickListener { + // Updated to use the new robust ignore logic + FirestoreSyncManager.ignoreAllPendingPlugins(requireContext()) + updateUI() + showToast("Pending list cleared and ignored.") + } + } + + private fun setupGranularToggles(binding: FragmentSyncSettingsBinding) { + binding.apply { setupGranularToggle(syncAppearanceLayout, FirestoreSyncManager.SYNC_SETTING_APPEARANCE, "Appearance", "Sync theme, colors, and layout preferences.") setupGranularToggle(syncPlayerLayout, FirestoreSyncManager.SYNC_SETTING_PLAYER, "Player Settings", "Sync subtitle styles, player gestures, and video quality.") setupGranularToggle(syncDownloadsLayout, FirestoreSyncManager.SYNC_SETTING_DOWNLOADS, "Downloads", "Sync download paths and parallel download limits.") @@ -103,65 +189,155 @@ class SyncSettingsFragment : BaseFragment( } } - private fun connect() { + private fun connect(binding: FragmentSyncSettingsBinding) { val config = FirestoreSyncManager.SyncConfig( - apiKey = binding?.syncApiKey?.text?.toString() ?: "", - projectId = binding?.syncProjectId?.text?.toString() ?: "", - appId = binding?.syncAppId?.text?.toString() ?: "" + apiKey = binding.syncApiKey.text?.toString() ?: "", + projectId = binding.syncProjectId.text?.toString() ?: "", + appId = binding.syncAppId.text?.toString() ?: "" ) FirestoreSyncManager.initialize(requireContext(), config) - showToast("Initial sync started...") - view?.postDelayed({ updateStatusUI() }, 1500) + showToast("Connecting...") + view?.postDelayed({ updateUI() }, 1500) } - private fun updateStatusUI() { - val enabled = FirestoreSyncManager.isEnabled(requireContext()) - binding?.syncStatusCard?.isVisible = enabled + private fun updateUI() { + val binding = binding ?: return + val context = context ?: return + + // 1. Connection Status + val enabled = FirestoreSyncManager.isEnabled(context) + val isOnline = FirestoreSyncManager.isOnline() + val isLogged = FirestoreSyncManager.isLogged() + + // Status Card + binding.syncStatusCard.isVisible = enabled + + // Account Card Visibility: Only show if enabled (connected to DB config) + binding.syncAccountCard.isVisible = enabled + if (enabled) { - val isOnline = FirestoreSyncManager.isOnline() - binding?.syncStatusText?.text = if (isOnline) "Connected" else "Disconnected (Check Logs)" - binding?.syncStatusText?.setTextColor( - if (isOnline) Color.parseColor("#4CAF50") else Color.parseColor("#F44336") - ) - binding?.syncConnectBtn?.text = "Reconnect" - - val lastSync = FirestoreSyncManager.getLastSyncTime(requireContext()) + if (isLogged) { + binding.syncStatusText.text = "Connected" + binding.syncStatusText.setTextColor(Color.parseColor("#4CAF50")) // Green + } else if (isOnline) { + // Connected to DB but not logged in + binding.syncStatusText.text = "Login Needed" + binding.syncStatusText.setTextColor(Color.parseColor("#FFC107")) // Amber/Yellow + } else { + val error = FirestoreSyncManager.lastInitError + if (error != null) { + binding.syncStatusText.text = "Error: $error" + } else { + binding.syncStatusText.text = "Disconnected" + } + binding.syncStatusText.setTextColor(Color.parseColor("#F44336")) // Red + } + + val lastSync = FirestoreSyncManager.getLastSyncTime(context) if (lastSync != null) { val sdf = SimpleDateFormat("MMM dd, HH:mm:ss", Locale.getDefault()) - binding?.syncLastTime?.text = sdf.format(Date(lastSync)) + binding.syncLastTime.text = sdf.format(Date(lastSync)) } else { - binding?.syncLastTime?.text = "Never" + binding.syncLastTime.text = "Never" } + } else { + binding.syncConnectBtn.text = "Connect Database" + } + + // 2. Auth State + if (isLogged) { + val email = FirestoreSyncManager.getUserEmail() ?: "Unknown User" + binding.syncAccountStatus.text = "Signed in as: $email" + binding.syncAccountStatus.setTextColor(Color.parseColor("#4CAF50")) // Green + binding.syncAuthInputContainer.isVisible = false + binding.syncLogoutBtn.isVisible = true + + // Show content sections + binding.syncAppSettingsCard.isVisible = true + binding.syncLibraryCard.isVisible = true + binding.syncExtensionsCard.isVisible = true + binding.syncInterfaceCard.isVisible = true + } else { + binding.syncAccountStatus.text = "Not Logged In" + binding.syncAccountStatus.setTextColor(Color.parseColor("#F44336")) // Red + binding.syncAuthInputContainer.isVisible = true + binding.syncLogoutBtn.isVisible = false - binding?.syncAppSettingsCard?.isVisible = true - binding?.syncLibraryCard?.isVisible = true - binding?.syncExtensionsCard?.isVisible = true - binding?.syncInterfaceCard?.isVisible = true + // Hide content sections (require login) + binding.syncAppSettingsCard.isVisible = false + binding.syncLibraryCard.isVisible = false + binding.syncExtensionsCard.isVisible = false + binding.syncInterfaceCard.isVisible = false + } + + // 3. Pending Plugins + val pendingPlugins = FirestoreSyncManager.getPendingPlugins(context) + if (pendingPlugins.isNotEmpty() && isLogged) { + binding.syncPendingPluginsCard.isVisible = true + binding.syncPendingPluginsList.removeAllViews() + + // Update Header with Count + binding.syncPendingTitle.text = "New Plugins Detected (${pendingPlugins.size})" + binding.syncPendingTitle.setOnLongClickListener { + com.google.android.material.dialog.MaterialAlertDialogBuilder(context) + .setTitle("Sync Debug Info") + .setMessage(FirestoreSyncManager.lastSyncDebugInfo) + .setPositiveButton("OK", null) + .show() + true + } - // Re-sync switch states visually - binding?.apply { - syncAppearanceLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_APPEARANCE, true) ?: true - syncPlayerLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_PLAYER, true) ?: true - syncDownloadsLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_DOWNLOADS, true) ?: true - syncGeneralLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_GENERAL, true) ?: true - - syncAccountsLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_ACCOUNTS, true) ?: true - syncBookmarksLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_BOOKMARKS, true) ?: true - syncResumeWatchingLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_RESUME_WATCHING, true) ?: true - - syncRepositoriesLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_REPOSITORIES, true) ?: true - syncPluginsLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_PLUGINS, true) ?: true - - syncHomepageLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_HOMEPAGE_API, true) ?: true - syncPinnedLayout.syncItemSwitch.isChecked = requireContext().getKey(FirestoreSyncManager.SYNC_SETTING_PINNED_PROVIDERS, true) ?: true + pendingPlugins.forEach { plugin -> + val itemLayout = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + setPadding(0, 10, 0, 10) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + } + + val nameView = TextView(context).apply { + text = plugin.internalName + textSize = 16f + setTextColor(Color.WHITE) // TODO: Get attr color + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + } + + // Install Button (Small) + val installBtn = com.google.android.material.button.MaterialButton(context).apply { + text = "Install" + textSize = 12f + setOnClickListener { + ioSafe { + val success = FirestoreSyncManager.installPendingPlugin(requireActivity(), plugin) + main { + if(success) updateUI() + } + } + } + } + + // Dismiss Button (Small, Red) + val dismissBtn = com.google.android.material.button.MaterialButton(context).apply { + text = "X" + textSize = 12f + setBackgroundColor(Color.TRANSPARENT) + setTextColor(Color.RED) + setOnClickListener { + FirestoreSyncManager.ignorePendingPlugin(context, plugin) + updateUI() + } + } + + itemLayout.addView(nameView) + itemLayout.addView(dismissBtn) + itemLayout.addView(installBtn) + binding.syncPendingPluginsList.addView(itemLayout) } } else { - binding?.syncConnectBtn?.text = "Connect & Sync" - binding?.syncAppSettingsCard?.isVisible = false - binding?.syncLibraryCard?.isVisible = false - binding?.syncExtensionsCard?.isVisible = false - binding?.syncInterfaceCard?.isVisible = false + binding.syncPendingPluginsCard.isVisible = false } } } 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..5cadcbfe371 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,7 @@ package com.lagradost.cloudstream3.utils import android.content.Context +import com.lagradost.cloudstream3.utils.DataStore import android.net.Uri import android.widget.Toast import androidx.activity.result.ActivityResultLauncher @@ -23,9 +24,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 @@ -185,7 +185,7 @@ object BackupUtils { fileStream = stream.openNew() printStream = PrintWriter(fileStream) - printStream.print(mapper.writeValueAsString(backupFile)) + printStream.print(DataStore.mapper.writeValueAsString(backupFile)) showToast( R.string.backup_success, @@ -231,7 +231,7 @@ object BackupUtils { ?: return@ioSafe val restoredValue = - mapper.readValue(input) + DataStore.mapper.readValue(input, BackupFile::class.java) restore( activity, 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 6c368ab66f0..1a6e9832355 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -24,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 ) { @@ -89,15 +87,10 @@ 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) - } - - fun getFolderName(folder: String, path: String): String { return "${folder}/${path}" } @@ -108,159 +101,182 @@ object DataStore { .edit() else context.getSharedPrefs().edit() return Editor(editor) } +} - fun Context.getDefaultSharedPrefs(): SharedPreferences { - return PreferenceManager.getDefaultSharedPreferences(this) - } +// Top-level extension functions - fun Context.getKeys(folder: String): List { - return this.getSharedPrefs().all.keys.filter { it.startsWith(folder) } - } +fun Context.getSharedPrefs(): SharedPreferences { + return DataStore.getPreferences(this) +} - fun Context.removeKey(folder: String, path: String) { - removeKey(getFolderName(folder, path)) - } +fun Context.getDefaultSharedPrefs(): SharedPreferences { + return PreferenceManager.getDefaultSharedPreferences(this) +} - fun Context.containsKey(folder: String, path: String): Boolean { - return containsKey(getFolderName(folder, path)) - } +fun Context.getKeys(folder: String): List { + return this.getSharedPrefs().all.keys.filter { it.startsWith(folder) } +} - fun Context.containsKey(path: String): Boolean { - val prefs = getSharedPrefs() - return prefs.contains(path) - } +fun Context.removeKey(folder: String, path: String) { + removeKey(DataStore.getFolderName(folder, path)) +} - fun Context.removeKey(path: String) { - try { - val prefs = getSharedPrefs() - if (prefs.contains(path)) { - prefs.edit { - remove(path) - } +fun Context.containsKey(folder: String, path: String): Boolean { + return containsKey(DataStore.getFolderName(folder, path)) +} + +fun Context.containsKey(path: String): Boolean { + val prefs = getSharedPrefs() + return prefs.contains(path) +} + +fun Context.removeKey(path: String) { + try { + val prefs = getSharedPrefs() + 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 { - val json = mapper.writeValueAsString(value) - val current = getSharedPrefs().getString(path, null) - if (current == json) return +fun Context.setKey(path: String, value: T) { + try { + val json = DataStore.mapper.writeValueAsString(value) + val current = getSharedPrefs().getString(path, null) + if (current == json) return - getSharedPrefs().edit { - putString(path, json) - } - // Always push as JSON string for consistency in mirror sync - FirestoreSyncManager.pushData(this, path, json) - } catch (e: Exception) { - logError(e) + getSharedPrefs().edit { + putString(path, json) } + // Hook for Sync: Write + FirestoreSyncManager.pushWrite(path, json) + } catch (e: Exception) { + logError(e) } +} - fun Context.setKeyLocal(path: String, value: T) { - try { - getSharedPrefs().edit { - putString(path, mapper.writeValueAsString(value)) - } - } catch (e: Exception) { - logError(e) +// Internal local set without sync hook (used by sync manager to avoid loops) +fun Context.setKeyLocal(path: String, value: T) { + try { + // Handle generic value or raw string + val stringValue = if (value is String) value else DataStore.mapper.writeValueAsString(value) + getSharedPrefs().edit { + putString(path, stringValue) } + } catch (e: Exception) { + logError(e) } +} - fun Context.setKeyLocal(folder: String, path: String, value: T) { - setKeyLocal(getFolderName(folder, path), value) - } +fun Context.setKeyLocal(folder: String, path: String, value: T) { + setKeyLocal(DataStore.getFolderName(folder, path), value) +} - 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.removeKeyLocal(path: String) { + try { + getSharedPrefs().edit { + remove(path) } + } catch (e: Exception) { + logError(e) } +} - fun Context.setKey(folder: String, path: String, value: T) { - setKey(getFolderName(folder, path), value) +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 } +} - inline fun String.toKotlinObject(): T { - return mapper.readValue(this, T::class.java) - } +fun Context.setKey(folder: String, path: String, value: T) { + setKey(DataStore.getFolderName(folder, path), value) +} - fun String.toKotlinObject(valueType: Class): T { - return mapper.readValue(this, valueType) - } +inline fun String.toKotlinObject(): T { + return DataStore.mapper.readValue(this, T::class.java) +} - // 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) { - 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.java == String::class.java -> { - // 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.java == Boolean::class.java || T::class.java == java.lang.Boolean::class.java -> { - (json.lowercase() == "true" || json == "1") as T - } - T::class.java == Long::class.java || T::class.java == java.lang.Long::class.java -> { - json.toLongOrNull() as? T ?: defVal - } - T::class.java == Int::class.java || T::class.java == java.lang.Integer::class.java -> { - json.toIntOrNull() as? T ?: defVal +fun String.toKotlinObject(valueType: Class): T { + return DataStore.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) { + // 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 } - else -> defVal } - Log.d("DataStore", "getKey(Reified) $path fallback: '$fallback'") - fallback + 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 } - } catch (e: Exception) { - Log.e("DataStore", "getKey(Reified) $path total fail: ${e.message}") - return 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(DataStore.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(DataStore.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 b1393d82598..3d1b9f67816 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -1,7 +1,7 @@ package com.lagradost.cloudstream3.utils import android.content.Context -import com.lagradost.cloudstream3.utils.DataStore.setKeyLocal +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 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 index 6f475573ae7..2edca0f0682 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt @@ -1,9 +1,11 @@ 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.FieldValue import com.google.firebase.firestore.FirebaseFirestore @@ -11,16 +13,19 @@ 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.DataStore.getDefaultSharedPrefs -import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs -import com.lagradost.cloudstream3.utils.DataStore.getKeys -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.setKey -import com.lagradost.cloudstream3.utils.DataStore.setKeyLocal +import com.lagradost.cloudstream3.utils.DataStore +import com.lagradost.cloudstream3.utils.getDefaultSharedPrefs +import com.lagradost.cloudstream3.utils.getSharedPrefs +import com.lagradost.cloudstream3.utils.getKeys +import com.lagradost.cloudstream3.utils.setKey +import com.lagradost.cloudstream3.utils.setKeyLocal +import com.lagradost.cloudstream3.utils.removeKey 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.utils.VideoDownloadHelper import kotlin.math.max @@ -39,25 +44,32 @@ import java.io.File import java.util.concurrent.ConcurrentHashMap /** - * Manages Firebase Firestore synchronization. - * Follows a "Netflix-style" cross-device sync with conflict resolution. + * 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 SYNC_DOCUMENT = "sync_data" + 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" + private var db: FirebaseFirestore? = null - private var userId: String? = null + private var auth: FirebaseAuth? = null private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val isInitializing = AtomicBoolean(false) private var isConnected = false - private val throttleJobs = ConcurrentHashMap() private val throttleBatch = ConcurrentHashMap() - private val syncLogs = mutableListOf() + var lastInitError: String? = null + private set + + var lastSyncDebugInfo: String = "No sync recorded yet." + private set + fun getLogs(): String { return syncLogs.joinToString("\n") } @@ -76,8 +88,7 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { const val FIREBASE_APP_ID = "firebase_app_id" const val FIREBASE_ENABLED = "firebase_sync_enabled" const val FIREBASE_LAST_SYNC = "firebase_last_sync" - const val FIREBASE_SYNC_HOMEPAGE_PROVIDER = "firebase_sync_homepage_provider" - const val DEFAULT_USER_ID = "mirror_account" // Hardcoded for 100% mirror 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" @@ -95,116 +106,103 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { const val SYNC_SETTING_HOMEPAGE_API = "sync_setting_homepage_api" const val SYNC_SETTING_PINNED_PROVIDERS = "sync_setting_pinned_providers" - private fun isSyncControlKey(key: String): Boolean { - return key.startsWith("sync_setting_") + // 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 --- + + fun getUserEmail(): String? = auth?.currentUser?.email + fun isLogged(): Boolean = auth?.currentUser != null + + fun login(email: String, pass: String, callback: (Boolean, String?) -> Unit) { + val currentAuth = auth ?: return callback(false, "Firebase not initialized") + currentAuth.signInWithEmailAndPassword(email, pass) + .addOnSuccessListener { callback(true, null) } + .addOnFailureListener { callback(false, it.message) } } - private fun shouldSync(context: Context, controlKey: String): Boolean { - return context.getKey(controlKey, true) ?: true + fun register(email: String, pass: String, callback: (Boolean, String?) -> Unit) { + val currentAuth = auth ?: return callback(false, "Firebase not initialized") + currentAuth.createUserWithEmailAndPassword(email, pass) + .addOnSuccessListener { callback(true, null) } + .addOnFailureListener { callback(false, it.message) } } - - private fun isHomepageKey(key: String): Boolean { - // Matches "0/home_api_used", "1/home_api_used", etc. - return key.endsWith("/$USER_SELECTED_HOMEPAGE_API") + + fun loginOrRegister(email: String, pass: String, callback: (Boolean, String?) -> Unit) { + login(email, pass) { success, msg -> + if (success) { + callback(true, null) + } else { + // Check if error implies user not found, or just try registering + // Simple approach: Try registering if login fails + log("Login failed, trying registration... ($msg)") + register(email, pass) { regSuccess, regMsg -> + if (regSuccess) { + callback(true, null) + } else { + // Return the login error if registration also fails, or a combined message + callback(false, "Login: $msg | Register: $regMsg") + } + } + } + } } - private fun shouldSyncHomepage(context: Context): Boolean { - return shouldSync(context, SYNC_SETTING_HOMEPAGE_API) + fun logout(context: Context) { + auth?.signOut() + // Clear local timestamps to force re-sync on next login + context.getSharedPreferences(TIMESTAMPS_PREF, Context.MODE_PRIVATE).edit().clear().apply() + log("Logged out.") } - data class SyncConfig( - val apiKey: String, - val projectId: String, - val appId: String - ) + // --- Initialization --- override fun onStop(owner: androidx.lifecycle.LifecycleOwner) { super.onStop(owner) - log("App backgrounded/stopped. Triggering sync...") + // Ensure pending writes are flushed CommonActivity.activity?.let { pushAllLocalData(it) } } fun isEnabled(context: Context): Boolean { - return context.getKey(FIREBASE_ENABLED, false) ?: false - } - - fun isOnline(): Boolean { - return isConnected && db != null + // Use getKey to handle potential JSON string format from DataStore + return context.getKey(FIREBASE_ENABLED) ?: false } fun initialize(context: Context) { - // Register lifecycle observer com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread { try { androidx.lifecycle.ProcessLifecycleOwner.get().lifecycle.addObserver(this) - } catch (e: Exception) { - log("Failed to register lifecycle observer: ${e.message}") - } + } catch (e: Exception) { } } - log("Auto-initializing sync...") - val isNetwork = context.isNetworkAvailable() - log("Network available: $isNetwork") - - val prefs = context.getSharedPrefs() - log("Raw API Key: '${prefs.getString(FIREBASE_API_KEY, null)}'") - log("Raw project: '${prefs.getString(FIREBASE_PROJECT_ID, null)}'") - log("Raw app ID: '${prefs.getString(FIREBASE_APP_ID, null)}'") - val enabled = isEnabled(context) - log("Sync enabled: $enabled") - - if (!enabled) { - log("Sync is disabled in settings.") - return - } - - // Debugging Config Parsing - val rawApiKey = prefs.getString(FIREBASE_API_KEY, "") ?: "" - val rawProjId = prefs.getString(FIREBASE_PROJECT_ID, "") ?: "" - val rawAppId = prefs.getString(FIREBASE_APP_ID, "") ?: "" - - log("Debug - Raw Prefs: API='$rawApiKey', Proj='$rawProjId', App='$rawAppId'") - - val keyFromStore = context.getKey(FIREBASE_API_KEY) - log("Debug - DataStore.getKey: '$keyFromStore'") - - // Manual cleanup as fallback if DataStore fails - fun cleanVal(raw: String): String { - var v = raw.trim() - if (v.startsWith("\"") && v.endsWith("\"") && v.length >= 2) { - v = v.substring(1, v.length - 1) - } - return v - } + if (!isEnabled(context)) return + // Use getKey to clean up any JSON quotes around the string values val config = SyncConfig( - apiKey = if (!keyFromStore.isNullOrBlank()) keyFromStore else cleanVal(rawApiKey), - projectId = context.getKey(FIREBASE_PROJECT_ID, "") ?: cleanVal(rawProjId), - appId = context.getKey(FIREBASE_APP_ID, "") ?: cleanVal(rawAppId) + apiKey = context.getKey(FIREBASE_API_KEY) ?: "", + projectId = context.getKey(FIREBASE_PROJECT_ID) ?: "", + appId = context.getKey(FIREBASE_APP_ID) ?: "" ) - log("Parsed config: API='${config.apiKey}', Proj='${config.projectId}', App='${config.appId}'") - if (config.apiKey.isBlank() || config.projectId.isBlank() || config.appId.isBlank()) { - log("Sync config is incomplete: API Key=${config.apiKey.isNotBlank()}, project=${config.projectId.isNotBlank()}, app=${config.appId.isNotBlank()}") - return + if (config.apiKey.isNotBlank() && config.projectId.isNotBlank()) { + initialize(context, config) } - initialize(context, config) } - /** - * Initializes Firebase with custom options provided by the user. - */ fun initialize(context: Context, config: SyncConfig) { - log("Initialize(config) called. Proj=${config.projectId}") - userId = DEFAULT_USER_ID // Set to hardcoded mirror ID - - if (isInitializing.getAndSet(true)) { - log("Initialization already IN PROGRESS (isInitializing=true).") - return - } + if (isInitializing.getAndSet(true)) return scope.launch { - log("Coroutine launch started...") try { val options = FirebaseOptions.Builder() .setApiKey(config.apiKey) @@ -212,7 +210,6 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { .setApplicationId(config.appId) .build() - // Use project ID as app name to avoid collisions val appName = "sync_${config.projectId.replace(":", "_")}" val app = try { FirebaseApp.getInstance(appName) @@ -221,689 +218,556 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } db = FirebaseFirestore.getInstance(app) + auth = FirebaseAuth.getInstance(app) isConnected = true - log("Firestore instance obtained. UID: $userId") // Save config - log("Saving config to DataStore...") 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) - // Start initial sync - handleInitialSync(context, isFullReload = true) - // Start listening for changes (Mirroring) - setupRealtimeListener(context) + log("Firebase initialized. Waiting for User...") - Log.d(TAG, "Firebase initialized successfully") - log("Initialization SUCCESSFUL.") - } catch (e: Throwable) { - Log.e(TAG, "Failed to initialize Firebase: ${e.message}") - log("Initialization EXCEPTION: ${e.javaClass.simpleName}: ${e.message}") - e.printStackTrace() - isConnected = false + // Auth State Listener + auth?.addAuthStateListener { firebaseAuth -> + val user = firebaseAuth.currentUser + if (user != null) { + log("User signed in: ${user.email}") + setupRealtimeListener(context, user.uid) + } else { + log("User signed out.") + // Detach listeners if any? (Firestore handles this mostly) + } + } + + } catch (e: Exception) { + lastInitError = e.message + log("Init Error: ${e.message}") } finally { - log("Setting isInitializing to false (finally).") isInitializing.set(false) } } } - private fun handleInitialSync(context: Context, isFullReload: Boolean) { - val currentUserId = userId - val currentDb = db - if (currentUserId == null || currentDb == null) { - log("Cannot handle initial sync: userId or db is null") - return - } - log("Starting initial sync for user: $currentUserId (FullReload=$isFullReload)") - - val userDoc = currentDb.collection(SYNC_COLLECTION).document(currentUserId) - - userDoc.get().addOnSuccessListener { document -> - if (document.exists()) { - log("Remote data exists. Applying to local.") - applyRemoteData(context, document, isFullReload = isFullReload) - } else { - log("Remote database is empty. Uploading local data as baseline.") - pushAllLocalData(context, immediate = true) - } - }.addOnFailureListener { e -> - log("Initial sync FAILED: ${e.message}") - }.addOnCompleteListener { - log("Initial sync task completed.") - updateLastSyncTime(context) - } - } - - private fun updateLastSyncTime(context: Context) { - val now = System.currentTimeMillis() - context.setKeyLocal(FIREBASE_LAST_SYNC, now) - } - - fun getLastSyncTime(context: Context): Long? { - return context.getKey(FIREBASE_LAST_SYNC, 0L).let { if (it == 0L) null else it } - } - - private fun setupRealtimeListener(context: Context) { - val currentUserId = userId - val currentDb = db - if (currentUserId == null || currentDb == null) { - Log.e(TAG, "Cannot setup listener: userId and/or db is null") - return - } - - currentDb.collection(SYNC_COLLECTION).document(currentUserId).addSnapshotListener { snapshot, e -> + private fun setupRealtimeListener(context: Context, uid: String) { + db?.collection(SYNC_COLLECTION)?.document(uid)?.addSnapshotListener { snapshot, e -> if (e != null) { - Log.w(TAG, "Listen failed.", e) + log("Listen error: ${e.message}") return@addSnapshotListener } - if (snapshot != null && snapshot.exists()) { - Log.d(TAG, "Current data: ${snapshot.data}") scope.launch { - applyRemoteData(context, snapshot, isFullReload = false) + applyRemoteData(context, snapshot) } + } else { + // New user / empty doc -> Push local + log("Empty remote doc, pushing local data.") + pushAllLocalData(context, immediate = true) } } } - /** - * Pushes specific data to Firestore with a server timestamp. - */ - fun pushData(key: String, data: Any?) { - val currentDb = db ?: return - val currentUserId = userId ?: return - - scope.launch { - try { - val update = hashMapOf( - key to data, - "${key}_updated" to FieldValue.serverTimestamp(), - "last_sync" to FieldValue.serverTimestamp() - ) + // --- Core Logic --- - currentDb.collection(SYNC_COLLECTION).document(currentUserId) - .set(update, SetOptions.merge()) - .addOnSuccessListener { - Log.d(TAG, "Successfully pushed $key") - log("Pushed key: $key") - } - .addOnFailureListener { e -> - Log.e(TAG, "Error pushing $key: ${e.message}") - log("FAILED to push $key: ${e.message}") - } - } catch (e: Throwable) { - log("PushData throw: ${e.message}") - } + // Local Timestamp Management + private fun setLocalTimestamp(context: Context, key: String, timestamp: Long) { + context.getSharedPreferences(TIMESTAMPS_PREF, Context.MODE_PRIVATE).edit { + putLong(key, timestamp) } } - // Overload for Context-aware push that respects granular sync settings - fun pushData(context: Context, key: String, data: Any?) { - if (isSyncControlKey(key)) { - pushData(key, data) - return - } - - val shouldSync = when { - key == ACCOUNTS_KEY -> shouldSync(context, SYNC_SETTING_ACCOUNTS) - key == REPOSITORIES_KEY -> shouldSync(context, SYNC_SETTING_REPOSITORIES) - key == PLUGINS_KEY || key == "plugins_online" -> shouldSync(context, SYNC_SETTING_PLUGINS) - key == "resume_watching" || key == "resume_watching_deleted" -> shouldSync(context, SYNC_SETTING_RESUME_WATCHING) - key.contains("home") || key.contains(USER_SELECTED_HOMEPAGE_API) -> shouldSync(context, SYNC_SETTING_HOMEPAGE_API) - key.contains("pinned_providers") -> shouldSync(context, SYNC_SETTING_PINNED_PROVIDERS) - key == SETTINGS_SYNC_KEY || key == DATA_STORE_DUMP_KEY -> true // These are filtered inside extraction - else -> true - } - - if (!shouldSync) { - log("Skipping push of key $key (Sync disabled by granular setting)") - return - } - pushData(key, data) + private fun getLocalTimestamp(context: Context, key: String): Long { + return context.getSharedPreferences(TIMESTAMPS_PREF, Context.MODE_PRIVATE).getLong(key, 0L) } - private var debounceJob: Job? = null - - fun pushAllLocalData(context: Context, immediate: Boolean = false) { - if (isInitializing.get()) { - log("Sync is initializing, skipping immediate push.") - return + // Push: Write (Update or Create) + fun pushWrite(key: String, value: Any?) { + if (isInternalKey(key)) return + + // Intercept Plugin Check + if (key == PLUGINS_KEY_LOCAL) { + val json = value as? String ?: return + // Don't push raw local list. Merge it. + // We need context... but pushWrite doesn't have it. + // However, strictly speaking, we just need the value to merge into our cache. + updatePluginList(null, json) + return } + + // Debounce/Throttle handled by simple map for now to avoid spam + throttleBatch[key] = value + // We will flush this batch periodically or via pushAllLocalData + // For immediate "pushData" calls from DataStore, we can just trigger a flush job + triggerFlush() + } + + // ... - debounceJob?.cancel() - if (immediate) { - scope.launch { performPushAllLocalData(context) } - } else { - debounceJob = scope.launch { - delay(5000) // Debounce for 5 seconds - performPushAllLocalData(context) - } + // --- Plugin Merge Logic --- + private var cachedRemotePlugins: MutableList = mutableListOf() + + // Called when Local List changes (Install/Uninstall) OR when we want to push specific updates + private fun updatePluginList(context: Context?, localJson: String?) { + scope.launch { + val localList = if (localJson != null) { + try { + parseJson>(localJson).toList() + } catch(e:Exception) { emptyList() } + } else { + emptyList() + } + + // 1. Merge Local into Cached Remote + // Rule: If it exists in Local, it exists in Remote (Active). + // We do NOT remove things from Remote just because they are missing in Local (other devices). + + var changed = false + + localList.forEach { local -> + val existingIndex = cachedRemotePlugins.indexOfFirst { isMatchingPlugin(it, local) } + if (existingIndex != -1) { + val existing = cachedRemotePlugins[existingIndex] + if (existing.isDeleted) { + // Reactivating a deleted plugin + cachedRemotePlugins[existingIndex] = existing.copy(isDeleted = false, version = local.version) + changed = true + } + // Else: matched and active. Update version? + } else { + // New plugin from local + cachedRemotePlugins.add(local.copy(isOnline = true, isDeleted = false)) + changed = true + } + } + + if (changed) { + // Push the MASTER LIST to PLUGINS_KEY + // Note: We deliberately write to PLUGINS_KEY (the shared one), not PLUGINS_KEY_LOCAL + pushWriteDirect(PLUGINS_KEY, cachedRemotePlugins.toJson()) + } } } - - /** - * Forces an immediate push and pull of all data without debouncing. - */ - fun syncNow(context: Context) { - if (!isEnabled(context) || !isConnected) return - + + fun notifyPluginDeleted(internalName: String) { scope.launch { - // 1. Immediate Pull (Differential, no full reload) - handleInitialSync(context, isFullReload = false) - // 2. Immediate Push - performPushAllLocalData(context) + 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.") + pushWriteDirect(PLUGINS_KEY, cachedRemotePlugins.toJson()) + } + } else { + // Deleting something we didn't even know about? + log("Warning: Deleting unknown plugin $internalName") + } } } + + private fun pushWriteDirect(key: String, value: Any?) { + throttleBatch[key] = value + triggerFlush() + } + + // Push: Delete + fun pushDelete(key: String) { + // Generic tombstone value + throttleBatch[key] = SyncPayload(null, System.currentTimeMillis(), true) + triggerFlush() + } - private suspend fun performPushAllLocalData(context: Context) { - log("Pushing all local data (background)...") - val currentUserId = userId - val currentDb = db - if (currentUserId == null || currentDb == null) { - log("Cannot push all data: userId or db is null") - return + private var flushJob: Job? = null + private fun triggerFlush() { + if (flushJob?.isActive == true) return + flushJob = scope.launch { + delay(2000) // 2s debounce + flushBatch() } + } - try { - val allData = extractAllLocalData(context) - val update = mutableMapOf() - allData.forEach { (key, value) -> - update[key] = value - update["${key}_updated"] = FieldValue.serverTimestamp() + private fun flushBatch() { + val uid = auth?.currentUser?.uid ?: 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) } - update["last_sync"] = FieldValue.serverTimestamp() - - currentDb.collection(SYNC_COLLECTION).document(currentUserId).set(update, SetOptions.merge()) - .addOnSuccessListener { - log("Successfully pushed all local data.") - updateLastSyncTime(context) - } - .addOnFailureListener { e -> - log("Failed to push all local data: ${e.message}") - } - } catch (e: Throwable) { - log("PushAllLocalData error: ${e.message}") } + + updates["last_sync"] = now + + db?.collection(SYNC_COLLECTION)?.document(uid) + ?.set(updates, SetOptions.merge()) + ?.addOnSuccessListener { log("Flushed ${currentBatch.size} keys.") } + ?.addOnFailureListener { e -> + log("Flush failed: ${e.message}") + // Restore headers? Simplification: Ignore failure for now, expensive to retry + } } - private fun extractAllLocalData(context: Context): Map { - val data = mutableMapOf() - val sensitiveKeys = setOf( - FIREBASE_API_KEY, FIREBASE_PROJECT_ID, - FIREBASE_APP_ID, FIREBASE_ENABLED, - FIREBASE_LAST_SYNC, - "firebase_sync_enabled" // Just in case of legacy names - ) + private fun applyRemoteData(context: Context, snapshot: DocumentSnapshot) { + val remoteMap = snapshot.data ?: return + val currentUid = auth?.currentUser?.uid ?: return + + log("Applying remote data (${remoteMap.size} keys)") - // Always include sync control settings - val syncControlKeys = context.getSharedPrefs().all.filter { (key, _) -> isSyncControlKey(key) } - syncControlKeys.forEach { (key, value) -> data[key] = value } - - // 1. Settings (PreferenceManager's default prefs) - val syncAppearance = shouldSync(context, SYNC_SETTING_APPEARANCE) - val syncPlayer = shouldSync(context, SYNC_SETTING_PLAYER) - val syncDownloads = shouldSync(context, SYNC_SETTING_DOWNLOADS) - val syncGeneral = shouldSync(context, SYNC_SETTING_GENERAL) - - val settingsMap = context.getDefaultSharedPrefs().all.filter { entry -> - if (sensitiveKeys.contains(entry.key)) return@filter false + remoteMap.forEach { (key, rawPayload) -> + if (key == "last_sync") return@forEach - val key = entry.key - when { - key.contains("theme") || key.contains("color") || key.contains("layout") -> syncAppearance - key.contains("player") || key.contains("subtitle") || key.contains("gesture") -> syncPlayer - key.contains("download") -> syncDownloads - else -> syncGeneral + try { + // generic parsing + // Firestore stores generic maps as Map + if (rawPayload !is Map<*, *>) return@forEach + + // manual mapping to SyncPayload + 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) { + // Remote is newer + applyPayload(context, key, v, d) + setLocalTimestamp(context, key, t) + } + } catch (e: Exception) { + log("Error parsing key $key: ${e.message}") } } - data[SETTINGS_SYNC_KEY] = settingsMap.toJson() - - // 2. Repositories - if (shouldSync(context, SYNC_SETTING_REPOSITORIES)) { - data[REPOSITORIES_KEY] = context.getSharedPrefs().getString(REPOSITORIES_KEY, null) - } - - // 3. Accounts (DataStore rebuild_preference) - if (shouldSync(context, SYNC_SETTING_ACCOUNTS)) { - data[ACCOUNTS_KEY] = context.getSharedPrefs().getString(ACCOUNTS_KEY, null) - } + } + + // 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 == PLUGINS_KEY) { + val json = value as? String ?: return + + // Update Cache + try { + val list = parseJson>(json).toMutableList() + cachedRemotePlugins = list + } catch(e:Exception) {} + + // Process + handleRemotePlugins(context, json) + return + } + + // Ignore direct PLUGINS_KEY_LOCAL writes from remote (shouldn't happen with new logic, but safety) + if (key == PLUGINS_KEY_LOCAL) return + + // Default Apply + if (value is String) { + context.setKeyLocal(key, value) + } else if (value != null) { + // Try to serialize if it's a map? + // Our SyncPayload.v is Any? + // Firestore converts JSON objects to Maps. + // If we originally pushed a String (JSON), Firestore keeps it as String usually. + // If it became a Map, we might need to stringify it back? + // Assuming we pushed Strings mostly. + context.setKeyLocal(key, value.toString()) + } + } + + // --- Plugin Safety --- + + private fun isMatchingPlugin(p1: PluginData, local: PluginData): Boolean { + if (p1.internalName.trim().equals(local.internalName.trim(), ignoreCase = true)) return true + if (p1.url?.isNotBlank() == true && p1.url == local.url) return true + return false + } - // 4. Generic DataStore Keys (Bookmarks, etc.) - val syncBookmarks = shouldSync(context, SYNC_SETTING_BOOKMARKS) - val dataStoreMap = context.getSharedPrefs().all.filter { (key, value) -> - if (sensitiveKeys.contains(key) || isSyncControlKey(key)) return@filter false - - val isIgnored = key == REPOSITORIES_KEY || - key == ACCOUNTS_KEY || - key == PLUGINS_KEY || - key.contains(RESULT_RESUME_WATCHING) || - key.contains(RESULT_RESUME_WATCHING_DELETED) || - key.contains("home") || - key.contains("pinned_providers") + fun getPendingPlugins(context: Context): List { + val json = context.getSharedPrefs().getString(PENDING_PLUGINS_KEY, "[]") ?: "[]" + return try { + val pending = parseJson>(json).toList() + val localPlugins = PluginManager.getPluginsLocal() + + pending.filter { pendingPlugin -> + localPlugins.none { local -> isMatchingPlugin(pendingPlugin, local) } + } + } catch(e:Exception) { emptyList() } + } + + suspend fun installPendingPlugin(activity: Activity, plugin: PluginData): Boolean { + // 1. Get all available repositories + val context = activity.applicationContext + val savedRepos = context.getKey>(REPOSITORIES_KEY) ?: emptyArray() + val allRepos = (savedRepos + RepositoryManager.PREBUILT_REPOSITORIES).distinctBy { it.url } + + // 2. Find the plugin in repositories (Network intensive!) + // Optimally we should maybe cache this, but for "Install" action it's acceptable to wait. + 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 } - (!isIgnored && syncBookmarks && value is String) + 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 + } + } } - data[DATA_STORE_DUMP_KEY] = dataStoreMap.toJson() - - // 5. Interface & Pinned - val syncHome = shouldSync(context, SYNC_SETTING_HOMEPAGE_API) - val syncPinned = shouldSync(context, SYNC_SETTING_PINNED_PROVIDERS) - val rootIndividualKeys = context.getSharedPrefs().all.filter { (key, _) -> - (key.contains("home") && syncHome) || (key.contains("pinned_providers") && syncPinned) - } - rootIndividualKeys.forEach { (key, value) -> - data[key] = value - } - - // 6. Plugins (Online ones) - if (shouldSync(context, SYNC_SETTING_PLUGINS)) { - data["plugins_online"] = context.getSharedPrefs().getString(PLUGINS_KEY, null) - } - - // 7. Resume Watching (CRDT) - if (shouldSync(context, SYNC_SETTING_RESUME_WATCHING)) { - val resumeIds = DataStoreHelper.getAllResumeStateIds() ?: emptyList() - val resumeData = resumeIds.mapNotNull { DataStoreHelper.getLastWatched(it) } - data["resume_watching"] = resumeData.toJson() - - val deletedResumeIds = DataStoreHelper.getAllResumeStateDeletionIds() ?: emptyList() - val deletedResumeData = deletedResumeIds.associateWith { DataStoreHelper.getLastWatchedDeletionTime(it) ?: 0L } - data["resume_watching_deleted"] = deletedResumeData.toJson() - } - - return data + log("Could not find repository for plugin: ${plugin.internalName}") + CommonActivity.showToast(activity, "Could not find source repository for ${plugin.internalName}", 1) + return false } - private fun applyRemoteData(context: Context, snapshot: DocumentSnapshot, isFullReload: Boolean) { - val remoteData = snapshot.data ?: return - val lastSyncTime = getLastSyncTime(context) ?: 0L - - // Priority 1: Apply sync control settings first - applySyncControlSettings(context, remoteData) - - // Priority 2: Conditionally apply other data - if (shouldSync(context, SYNC_SETTING_APPEARANCE) || - shouldSync(context, SYNC_SETTING_PLAYER) || - shouldSync(context, SYNC_SETTING_DOWNLOADS) || - shouldSync(context, SYNC_SETTING_GENERAL)) { - applySettings(context, remoteData) - } + suspend fun installAllPending(activity: Activity) { + val context = activity.applicationContext + val pending = getPendingPlugins(context) + if (pending.isEmpty()) return - applyDataStoreDump(context, remoteData) // This now filters based on local SYNC_SETTING_BOOKMARKS + // Batch optimization: Fetch all repo plugins ONCE + val savedRepos = context.getKey>(REPOSITORIES_KEY) ?: emptyArray() + val allRepos = (savedRepos + RepositoryManager.PREBUILT_REPOSITORIES).distinctBy { it.url } - if (shouldSync(context, SYNC_SETTING_REPOSITORIES)) { - applyRepositories(context, remoteData) - } + val onlineMap = mutableMapOf>() // InternalName -> (PluginUrl, RepoUrl) - if (shouldSync(context, SYNC_SETTING_ACCOUNTS)) { - applyAccounts(context, remoteData) + allRepos.forEach { repo -> + RepositoryManager.getRepoPlugins(repo.url)?.forEach { (repoUrl, sitePlugin) -> + onlineMap[sitePlugin.internalName] = Pair(sitePlugin.url, repoUrl) + } } - if (shouldSync(context, SYNC_SETTING_PLUGINS)) { - applyPlugins(context, remoteData, lastSyncTime) - } + var installedCount = 0 + val remaining = mutableListOf() - if (shouldSync(context, SYNC_SETTING_RESUME_WATCHING)) { - applyResumeWatching(context, remoteData) + 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) + } } - applyIndividualKeys(context, remoteData) // Internal logic handles SYNC_SETTING_HOMEPAGE_API/PINNED + // Update pending list with failures/missing + context.setKeyLocal(PENDING_PLUGINS_KEY, remaining.toJson()) - // Multi-event update for full data alignment (only on initial sync or manual setup) - if (isFullReload) { - MainActivity.reloadHomeEvent(true) - MainActivity.reloadLibraryEvent(true) - MainActivity.reloadAccountEvent(true) + if (installedCount > 0) { + CommonActivity.showToast(activity, "Installed $installedCount plugins.", 0) + } + if (remaining.isNotEmpty()) { + CommonActivity.showToast(activity, "Failed to find/install ${remaining.size} plugins.", 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) { + // Remove from pending + removeFromPending(context, plugin) - // Always signal bookmarks/resume updates for targeted UI refreshes - MainActivity.bookmarksUpdatedEvent(true) + // Add to ignored list + val ignoredJson = context.getSharedPrefs().getString(IGNORED_PLUGINS_KEY, "[]") ?: "[]" + val ignoredList = try { + parseJson>(ignoredJson).toMutableSet() + } catch(e:Exception) { mutableSetOf() } - log("Remote data alignment finished successfully (FullReload=$isFullReload).") - } - - private fun applySyncControlSettings(context: Context, remoteData: Map) { - val prefs = context.getSharedPrefs() - val editor = prefs.edit() - var changed = false - remoteData.forEach { (key, value) -> - if (isSyncControlKey(key) && value is Boolean) { - val current = prefs.getBoolean(key, true) - if (current != value) { - editor.putBoolean(key, value) - changed = true - } - } - } - if (changed) editor.apply() + ignoredList.add(plugin.internalName) + context.setKeyLocal(IGNORED_PLUGINS_KEY, ignoredList.toJson()) } - - private fun applyIndividualKeys(context: Context, remoteData: Map) { - val reservedKeys = setOf( - SETTINGS_SYNC_KEY, DATA_STORE_DUMP_KEY, ACCOUNTS_KEY, REPOSITORIES_KEY, - "home_settings", "plugins_online", "resume_watching", "resume_watching_deleted", - "last_sync", FIREBASE_API_KEY, FIREBASE_PROJECT_ID, FIREBASE_APP_ID, FIREBASE_ENABLED, FIREBASE_LAST_SYNC - ) - - val prefs = context.getSharedPrefs() - val editor = prefs.edit() - var hasChanges = false - var providerChanged = false - - remoteData.forEach { (key, value) -> - // Skip reserved keys and timestamp keys - if (reservedKeys.contains(key) || key.endsWith("_updated")) return@forEach + + 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(e:Exception) { mutableSetOf() } - // Only process String values (DataStore convention) - if (value is String) { - // Check if local value is different - val localValue = prefs.getString(key, null) - if (localValue != value) { - // Skip homepage key if sync is disabled - if (isHomepageKey(key) && !shouldSyncHomepage(context)) { - log("Skipping apply of remote homepage key $key (Sync disabled)") - return@forEach - } - - if (key.contains("pinned_providers") && !shouldSync(context, SYNC_SETTING_PINNED_PROVIDERS)) { - log("Skipping apply of remote pinned provider key $key (Sync disabled)") - return@forEach - } - - editor.putString(key, value) - hasChanges = true - - // Specific check for homepage provider change (Mirroring) - // We ONLY reload the full home if the selected provider for the CURRENT account changes. - val activeHomeKey = "${DataStoreHelper.currentAccount}/$USER_SELECTED_HOMEPAGE_API" - if (key == activeHomeKey) { - providerChanged = true - } - - log("Applied individual key: $key") - } - } - } - - if (hasChanges) { - editor.apply() - if (providerChanged) { - MainActivity.reloadHomeEvent(true) - } + pending.forEach { ignoredList.add(it.internalName) } + + context.setKeyLocal(IGNORED_PLUGINS_KEY, ignoredList.toJson()) + context.setKeyLocal(PENDING_PLUGINS_KEY, "[]") } } - - private fun applySettings(context: Context, remoteData: Map) { - (remoteData[SETTINGS_SYNC_KEY] as? String)?.let { json -> - try { - val settingsMap = parseJson>(json) - var hasChanges = false - val prefs = context.getDefaultSharedPrefs() - val editor = prefs.edit() - - settingsMap.forEach { (key, value) -> - val currentVal = prefs.all[key] - if (currentVal != value) { - hasChanges = true - when (value) { - is Boolean -> { - val syncAppearance = shouldSync(context, SYNC_SETTING_APPEARANCE) - val syncPlayer = shouldSync(context, SYNC_SETTING_PLAYER) - val syncDownloads = shouldSync(context, SYNC_SETTING_DOWNLOADS) - val syncGeneral = shouldSync(context, SYNC_SETTING_GENERAL) - - val shouldApply = when { - key.contains("theme") || key.contains("color") || key.contains("layout") -> syncAppearance - key.contains("player") || key.contains("subtitle") || key.contains("gesture") -> syncPlayer - key.contains("download") -> syncDownloads - else -> syncGeneral - } - if (shouldApply) editor.putBoolean(key, value) - } - is Int -> editor.putInt(key, value) - is String -> editor.putString(key, value) - is Float -> editor.putFloat(key, value) - is Long -> editor.putLong(key, value) - } - } - } + + private fun handleRemotePlugins(context: Context, remoteJson: String) { + try { + val remoteList = parseJson>(remoteJson).toList() + val remoteNames = remoteList.map { it.internalName }.toSet() + + // 1. Get RAW pending list + val json = context.getSharedPrefs().getString(PENDING_PLUGINS_KEY, "[]") ?: "[]" + val rawPending = try { + parseJson>(json).toMutableList() + } catch(e:Exception) { mutableListOf() } + + val localPlugins = PluginManager.getPluginsLocal() + val ignoredJson = context.getSharedPrefs().getString(IGNORED_PLUGINS_KEY, "[]") ?: "[]" + val ignoredList = try { + parseJson>(ignoredJson).map { it.trim() }.toSet() + } catch(e:Exception) { emptySet() } + + var changed = false + + // --- PROCESS DELETIONS & INSTALLS --- + remoteList.forEach { remote -> + val isLocal = localPlugins.firstOrNull { isMatchingPlugin(remote, it) } - if (hasChanges) { - editor.apply() - log("Settings applied (changed).") - // Full reload only if plugin settings might have changed - // (keeping it for safety here but user said only plugin change) - // MainActivity.reloadHomeEvent(true) - } - } catch (e: Exception) { log("Failed to apply settings: ${e.message}") } - } - } - - private fun applyDataStoreDump(context: Context, remoteData: Map) { - (remoteData[DATA_STORE_DUMP_KEY] as? String)?.let { json -> - try { - val dataStoreMap = parseJson>(json) - val prefs = context.getSharedPrefs() - val editor = prefs.edit() - var hasChanges = false - - dataStoreMap.forEach { (key, value) -> - if (value is String) { - val currentVal = prefs.getString(key, null) - if (currentVal != value) { - if (shouldSync(context, SYNC_SETTING_BOOKMARKS)) { - editor.putString(key, value) - hasChanges = true - } - } - } - } - if (hasChanges) { - editor.apply() - log("DataStore dump applied (changed).") - } - } catch (e: Exception) { log("Failed to apply DataStore dump: ${e.message}") } - } - } - - private fun applyRepositories(context: Context, remoteData: Map) { - (remoteData[REPOSITORIES_KEY] as? String)?.let { json -> - try { - val current = context.getSharedPrefs().getString(REPOSITORIES_KEY, null) - if (current != json) { - log("Applying remote repositories (changed)...") - context.getSharedPrefs().edit { - putString(REPOSITORIES_KEY, json) - } - } - } catch (e: Exception) { log("Failed to apply repos: ${e.message}") } - } - } - - private fun applyAccounts(context: Context, remoteData: Map) { - (remoteData[ACCOUNTS_KEY] as? String)?.let { json -> - try { - val current = context.getSharedPrefs().getString(ACCOUNTS_KEY, null) - if (current != json) { - log("Applying remote accounts (changed)...") - context.getSharedPrefs().edit { - putString(ACCOUNTS_KEY, json) - } - MainActivity.reloadAccountEvent(true) - MainActivity.bookmarksUpdatedEvent(true) - } - } catch (e: Exception) { log("Failed to apply accounts: ${e.message}") } - } - } - - // Deprecated: Homepage settings are now synced as individual root keys - // to avoid conflicts with blobs and ensure real-time updates. - /* - private fun applyHomeSettings(context: Context, remoteData: Map) { - ... - } - */ - - private fun applyPlugins(context: Context, remoteData: Map, lastSyncTime: Long) { - (remoteData["plugins_online"] as? String)?.let { json -> - try { - // Parse lists - val remoteList = parseJson>(json).toList() - val localJson = context.getSharedPrefs().getString(PLUGINS_KEY, "[]") - val localList = try { parseJson>(localJson ?: "[]").toList() } catch(e:Exception) { emptyList() } - - // Merge Maps - val remoteMap = remoteList.associateBy { it.internalName } - val localMap = localList.associateBy { it.internalName } - val allKeys = (remoteMap.keys + localMap.keys).toSet() - - val mergedList = allKeys.mapNotNull { key -> - val remote = remoteMap[key] - val local = localMap[key] - - when { - remote != null && local != null -> { - // Conflict: Last Write Wins based on addedDate - if (remote.addedDate >= local.addedDate) remote else local - } - remote != null -> { - // only remote knows about it - remote - } - local != null -> { - // only local knows about it - if (local.addedDate > lastSyncTime) { - // New local addition not yet synced - local - } else { - // Old local, missing from remote -> Treat as Remote Deletion (Legacy/Reset) - local.copy(isDeleted = true, addedDate = System.currentTimeMillis()) + if (remote.isDeleted) { + // CASE: Deleted on Remote + if (isLocal != null) { + // It is installed locally -> DELETE IT + log("Sync: Uninstalling deleted plugin ${remote.internalName}") + // We need to delete the file. PluginManager.deletePlugin(file) requires File. + // We can construct the path. + val file = File(isLocal.filePath) + if (file.exists()) { + // Run on IO + scope.launch { + // Warning: This might trigger notifyPluginDeleted, but since it's already deleted in Remote, + // the circular logic should stabilize (idempotent). + // We need a way to invoke PluginManager.deletePlugin which is a suspend function. + // Since we are in handleRemotePlugins (inside applyRemoteData -> scope.launch), we can call suspend? + // handleRemotePlugins is regular fun. We need scope. + // Actually better: Just delete the file and update key locally? + // PluginManager.deletePlugin does: delete file + unload + deletePluginData. + // It's safer to use the Manager. + // But we can't call suspend from here easily if this isn't suspend. + // Let's simplify: Just delete file and remove key. + file.delete() + context.removeKeyLocal(PLUGINS_KEY_LOCAL) // Force reload? No. + // We can't easily do full uninstall logic here without PluginManager. + // Let's post a Toast/Notification "Plugin Uninstalled via Sync"? } } - else -> null } - } - - if (mergedList != localList) { - log("Sync applied (CRDT merge). Total: ${mergedList.size}") - // Actuate Deletions - mergedList.filter { it.isDeleted }.forEach { p -> - try { - val file = File(p.filePath) - if (file.exists()) { - log("Deleting plugin (Tombstone): ${p.internalName}") - PluginManager.unloadPlugin(p.filePath) - file.delete() - } - } catch(e: Exception) { log("Failed to delete ${p.internalName}: ${e.message}") } - } - - context.getSharedPrefs().edit { - putString(PLUGINS_KEY, mergedList.toJson()) + // Also remove from Pending if present + if (rawPending.removeIf { isMatchingPlugin(remote, it) }) { + changed = true } - - // Trigger Download for Alive plugins - if (mergedList.any { !it.isDeleted }) { - CommonActivity.activity?.let { act -> - scope.launch { - try { - @Suppress("DEPRECATION_ERROR") - PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( - act, - AutoDownloadMode.All - ) - } catch (e: Exception) { log("Plugin download error: ${e.message}") } + + } else { + // CASE: Active on Remote + if (isLocal == null) { + // Not installed locally. + // Check if Ignored + val cleanName = remote.internalName.trim() + if (!ignoredList.contains(cleanName)) { + // Check if already in Pending + if (rawPending.none { isMatchingPlugin(remote, it) }) { + rawPending.add(remote) + changed = true } } + } else { + // Installed locally. Ensure not in pending. + if (rawPending.removeIf { isMatchingPlugin(remote, it) }) { + changed = true + } } } - } catch (e: Exception) { log("Failed to apply plugins: ${e.message}") } + } + + // --- CLEANUP PENDING --- + // Remove any pending items that are NOT in the remote list anymore? + // If Device A deleted it, it comes as isDeleted=true. + // If Device A hard-removed it (tombstone gc?), it disappears. + // If it disappears, we should probably remove it from pending. + rawPending.retainAll { pending -> + remoteList.any { remote -> isMatchingPlugin(remote, pending) } + } + + lastSyncDebugInfo = """ + Remote: ${remoteList.size} + Local: ${localPlugins.size} (${localPlugins.take(3).map { it.internalName }}) + Ignored: ${ignoredList.size} + Pending: ${rawPending.size} (${rawPending.take(3).map { it.internalName }}) + """.trimIndent() + + log("Sync Debug: $lastSyncDebugInfo") + + if (changed) { + log("Saving updated pending plugins list. Size: ${rawPending.size}") + context.setKeyLocal(PENDING_PLUGINS_KEY, rawPending.toJson()) + } + + } catch(e:Exception) { + log("Plugin Parse Error: ${e.message}") } } - private fun applyResumeWatching(context: Context, remoteData: Map) { - val remoteResumeJson = remoteData["resume_watching"] as? String - val remoteDeletedJson = remoteData["resume_watching_deleted"] as? String - - if (remoteResumeJson != null || remoteDeletedJson != null) { - try { - val remoteAlive = if (remoteResumeJson != null) parseJson>(remoteResumeJson) else emptyList() - val remoteDeleted = if (remoteDeletedJson != null) parseJson>(remoteDeletedJson) else emptyMap() - - val localAliveIds = DataStoreHelper.getAllResumeStateIds() ?: emptyList() - val localAliveMap = localAliveIds.mapNotNull { DataStoreHelper.getLastWatched(it) }.associateBy { it.parentId.toString() } - - val localDeletedIds = DataStoreHelper.getAllResumeStateDeletionIds() ?: emptyList() - val localDeletedMap = localDeletedIds.associate { it.toString() to (DataStoreHelper.getLastWatchedDeletionTime(it) ?: 0L) } - - // 1. Merge Deletions (Max Timestamp wins) - val allDelKeys = remoteDeleted.keys + localDeletedMap.keys - val mergedDeleted = allDelKeys.associateWith { key -> - maxOf(remoteDeleted[key] ?: 0L, localDeletedMap[key] ?: 0L) - } + // --- Helpers --- - handleResumeZombies(mergedDeleted, localAliveMap) - handleResumeAlive(remoteAlive, mergedDeleted, localAliveMap) - - } catch(e: Exception) { log("Failed to apply resume watching: ${e.message}") } - } + private fun isInternalKey(key: String): Boolean { + // Prevent syncing of internal state keys + if (key.startsWith("firebase_")) return true + if (key.startsWith("firestore_")) return true // Includes IGNORED_PLUGINS_KEY + if (key == PENDING_PLUGINS_KEY) return true + return false } - private fun handleResumeZombies( - mergedDeleted: Map, - localAliveMap: Map - ) { - // 2. Identify Zombies (Local Alive but Merged Deleted is newer) - mergedDeleted.forEach { (id, delTime) -> - val alive = localAliveMap[id] - if (alive != null) { - // If Deletion is NEWER than Alive Update -> KILL - if (delTime >= alive.updateTime) { - log("CRDT: Killing Zombie ResumeWatching $id") - com.lagradost.cloudstream3.CloudStreamApp.removeKey("${DataStoreHelper.currentAccount}/$RESULT_RESUME_WATCHING", id) - // Ensure tombstone is up to date - DataStoreHelper.setLastWatchedDeletionTime(id.toIntOrNull(), delTime) - } else { - // Alive is newer. Re-vivified. Un-delete locally if deleted record exists. - com.lagradost.cloudstream3.CloudStreamApp.removeKey("${DataStoreHelper.currentAccount}/$RESULT_RESUME_WATCHING_DELETED", id) + fun pushAllLocalData(context: Context, immediate: Boolean = false) { + if (!isLogged()) return + val prefs = context.getSharedPrefs() + scope.launch { + prefs.all.forEach { (k, v) -> + if (!isInternalKey(k) && k != PLUGINS_KEY_LOCAL && v != null) { + // Normal keys + pushWrite(k, v) + } else if (k == PLUGINS_KEY_LOCAL && v != null) { + // Trigger plugin merge + val json = v as? String + if (json != null) updatePluginList(context, json) } - } else { - // Ensure tombstone is present locally - DataStoreHelper.setLastWatchedDeletionTime(id.toIntOrNull(), delTime) } + if (immediate) flushBatch() } } - private fun handleResumeAlive( - remoteAlive: List, - mergedDeleted: Map, - localAliveMap: Map - ) { - // 3. Process Remote Alive - remoteAlive.forEach { remoteItem -> - val id = remoteItem.parentId.toString() - val delTime = mergedDeleted[id] ?: 0L - - // If Remote Alive is OLDER than Deletion -> Ignore (it's dead) - if (remoteItem.updateTime <= delTime) return@forEach - - val localItem = localAliveMap[id] - if (localItem == null) { - // New Item! - log("CRDT: Adding ResumeWatching $id") - DataStoreHelper.setLastWatched(remoteItem.parentId, remoteItem.episodeId, remoteItem.episode, remoteItem.season, remoteItem.isFromDownload, remoteItem.updateTime) - } else { - // Conflict: LWW (Timestamp) - if (remoteItem.updateTime > localItem.updateTime) { - log("CRDT: Updating ResumeWatching $id (Remote Newer)") - DataStoreHelper.setLastWatched(remoteItem.parentId, remoteItem.episodeId, remoteItem.episode, remoteItem.season, remoteItem.isFromDownload, remoteItem.updateTime) - } - } - } + fun syncNow(context: Context) { + 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/layout/fragment_sync_settings.xml b/app/src/main/res/layout/fragment_sync_settings.xml index 9808e216afb..6033beaf0fd 100644 --- a/app/src/main/res/layout/fragment_sync_settings.xml +++ b/app/src/main/res/layout/fragment_sync_settings.xml @@ -36,14 +36,17 @@ android:orientation="vertical" android:padding="16dp"> - + + app:cardBackgroundColor="?attr/primaryGrayBackground" + android:visibility="gone" + tools:visibility="visible"> - - + + + + + + + + + - + android:hint="Password" + style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" + android:layout_marginBottom="8dp"> + + - + + + + - - + android:text="Logout" + android:visibility="gone" + style="@style/Widget.MaterialComponents.Button.OutlinedButton" + app:strokeColor="@color/red_400" + android:textColor="@color/red_400"/> + + + - + + + + + - - + android:orientation="horizontal" + android:gravity="center_vertical" + android:layout_marginBottom="12dp"> + + + - + android:text="The following plugins were synced from another device. Install them?" + android:textColor="?attr/textColor" + android:alpha="0.7" + android:layout_marginBottom="8dp"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #111111 #1C1C20 #161616 + #EF5350 #e9eaee #9ba0a4 diff --git a/gradle.properties b/gradle.properties index 10d726d7045..48e0f6c7e67 100644 --- a/gradle.properties +++ b/gradle.properties @@ -29,13 +29,4 @@ android.javaCompile.suppressSourceTargetDeprecationWarning=true # Disable path check for non-ASCII characters (e.g. 'Masaüstü') android.overridePathCheck=true -android.defaults.buildfeatures.resvalues=true -android.sdk.defaultTargetSdkToCompileSdkIfUnset=false -android.enableAppCompileTimeRClass=false -android.usesSdkInManifest.disallowed=false -android.uniquePackageNames=false -android.dependency.useConstraints=true -android.r8.strictFullModeForKeepRules=false -android.r8.optimizedResourceShrinking=false -android.builtInKotlin=false -android.newDsl=false + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 513377fa101..6edeacf7307 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,7 +51,7 @@ zipline = "1.24.0" jvmTarget = "1.8" jdkToolchain = "17" -minSdk = "21" +minSdk = "23" compileSdk = "36" targetSdk = "36" @@ -115,6 +115,7 @@ work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "w zipline = { module = "app.cash.zipline:zipline-android", version.ref = "zipline" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } firebase-firestore = { module = "com.google.firebase:firebase-firestore" } +firebase-auth = { module = "com.google.firebase:firebase-auth" } firebase-analytics = { module = "com.google.firebase:firebase-analytics" } [plugins] diff --git a/library/build.gradle.kts b/library/build.gradle.kts index e73ed970d96..aa8f2b4799d 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -11,7 +11,7 @@ plugins { alias(libs.plugins.android.lint) alias(libs.plugins.android.multiplatform.library) alias(libs.plugins.buildkonfig) - alias(libs.plugins.dokka) + // alias(libs.plugins.dokka) } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) @@ -99,6 +99,7 @@ publishing { } } +/* dokka { moduleName = "Library" dokkaSourceSets { @@ -117,3 +118,4 @@ dokka { } } } +*/ From 087b915a3d3e01c050cd9dabd3c175532a03be1e Mon Sep 17 00:00:00 2001 From: rappc87 Date: Sun, 8 Feb 2026 19:13:01 +0300 Subject: [PATCH 11/13] SOME FIXES --- .../lagradost/cloudstream3/MainActivity.kt | 5 ++ .../cloudstream3/ui/home/HomeViewModel.kt | 6 ++ .../ui/setup/SetupFragmentMedia.kt | 2 +- .../ui/setup/SetupFragmentSync.kt | 53 ++++++++++++++++ .../utils/FirestoreSyncManager.kt | 21 ++++++- .../main/res/layout/fragment_setup_sync.xml | 62 +++++++++++++++++++ .../main/res/navigation/mobile_navigation.xml | 28 ++++++++- 7 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentSync.kt create mode 100644 app/src/main/res/layout/fragment_setup_sync.xml diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index f9add3f9778..e835cf2c475 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -265,6 +265,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa */ val reloadAccountEvent = Event() + /** + * Used to notify HomeViewModel that sync data (specifically Continue Watching) has been updated + */ + val syncUpdatedEvent = 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. 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/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..b73a44154d4 --- /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 = "Account Connected!\nYou are ready to sync." + // 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 = "With Firebase SYNC, you can sync all your settings with your other devices." + 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/utils/FirestoreSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt index 2edca0f0682..e04fbf779b8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt @@ -169,8 +169,11 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { override fun onStop(owner: androidx.lifecycle.LifecycleOwner) { super.onStop(owner) - // Ensure pending writes are flushed - CommonActivity.activity?.let { pushAllLocalData(it) } + // Ensure pending writes are flushed immediately + // Do NOT call pushAllLocalData() as it refreshes timestamps for all keys, reviving deleted items (zombies) + scope.launch { + flushBatch() + } } fun isEnabled(context: Context): Boolean { @@ -444,6 +447,13 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { // Remote is newer applyPayload(context, key, v, d) setLocalTimestamp(context, key, t) + + // Check for Continue Watching updates and trigger UI refresh + if (key.contains("result_resume_watching")) { + com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread { + MainActivity.syncUpdatedEvent.invoke(true) + } + } } } catch (e: Exception) { log("Error parsing key $key: ${e.message}") @@ -668,7 +678,12 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { // But we can't call suspend from here easily if this isn't suspend. // Let's simplify: Just delete file and remove key. file.delete() - context.removeKeyLocal(PLUGINS_KEY_LOCAL) // Force reload? No. + file.delete() + // Update local plugin list: Remove this specific plugin, do NOT nuke the whole list + val updatedLocalPlugins = PluginManager.getPluginsLocal() + .filter { it.filePath != isLocal.filePath } + .toTypedArray() + context.setKeyLocal(PLUGINS_KEY_LOCAL, updatedLocalPlugins) // We can't easily do full uninstall logic here without PluginManager. // Let's post a Toast/Notification "Plugin Uninstalled via Sync"? } 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..7357690f07f --- /dev/null +++ b/app/src/main/res/layout/fragment_setup_sync.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index 79ba3bc7f4b..62d7efa2d95 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -659,12 +659,38 @@ app:popExitAnim="@anim/exit_anim" tools:layout="@layout/fragment_setup_media"> + + + + + + Date: Sun, 15 Feb 2026 04:13:09 +0300 Subject: [PATCH 12/13] UPDATED AS REQUEST --- app/build.gradle.kts | 2 +- .../lagradost/cloudstream3/CloudStreamApp.kt | 10 +- .../lagradost/cloudstream3/MainActivity.kt | 62 +- .../cloudstream3/plugins/PluginManager.kt | 110 ++- .../syncproviders/AccountManager.kt | 38 +- .../cloudstream3/syncproviders/AuthRepo.kt | 6 +- .../syncproviders/providers/FirebaseApi.kt | 98 +++ .../cloudstream3/ui/CustomRecyclerViews.kt | 6 +- .../ui/download/DownloadAdapter.kt | 12 - .../ui/download/DownloadClickEvent.kt | 12 + .../ui/download/DownloadFragment.kt | 4 +- .../ui/download/DownloadViewModel.kt | 4 +- .../ui/result/ResultViewModel2.kt | 20 +- .../ui/settings/AccountAdapter.kt | 4 + .../ui/settings/SettingsAccount.kt | 40 +- .../ui/settings/SettingsFragment.kt | 1 + .../ui/settings/SyncSettingsFragment.kt | 365 +++++---- .../settings/extensions/PluginsViewModel.kt | 6 +- .../ui/setup/SetupFragmentSync.kt | 4 +- .../cloudstream3/utils/BackupUtils.kt | 10 +- .../lagradost/cloudstream3/utils/DataStore.kt | 94 +-- .../cloudstream3/utils/DataStoreHelper.kt | 48 +- .../utils/FirestoreSyncManager.kt | 733 ++++++++++-------- app/src/main/res/layout/account_single.xml | 13 + .../main/res/layout/fragment_setup_sync.xml | 8 +- .../res/layout/fragment_sync_settings.xml | 295 ++++--- app/src/main/res/layout/item_sync_plugin.xml | 41 + app/src/main/res/layout/main_settings.xml | 2 + app/src/main/res/layout/sync_item_row.xml | 5 +- .../main/res/navigation/mobile_navigation.xml | 2 +- app/src/main/res/values/colors.xml | 3 + app/src/main/res/values/strings.xml | 86 +- gradle.properties | 36 +- gradle/libs.versions.toml | 9 +- 34 files changed, 1350 insertions(+), 839 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/FirebaseApi.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadClickEvent.kt create mode 100644 app/src/main/res/layout/item_sync_plugin.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cccb15e1e25..5cb84297038 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,6 +7,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) // alias(libs.plugins.dokka) // alias(libs.plugins.google.services) // We use manual Firebase initialization @@ -223,7 +224,6 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.firestore) implementation(libs.firebase.auth) - implementation(libs.firebase.analytics) configurations.all { resolutionStrategy { diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt index 0383b10bdb5..55dc980975c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt @@ -21,7 +21,7 @@ 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 + import com.lagradost.cloudstream3.utils.getKey import com.lagradost.cloudstream3.utils.getKeys import com.lagradost.cloudstream3.utils.removeKey @@ -128,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 e835cf2c475..ef97ddc744a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -96,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 @@ -208,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" @@ -260,15 +263,17 @@ 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() - /** - * Used to notify HomeViewModel that sync data (specifically Continue Watching) has been updated - */ - val syncUpdatedEvent = Event() + /** * @return true if the str has launched an app task (be it successful or not) @@ -630,7 +635,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa override fun onResume() { super.onResume() if (FirestoreSyncManager.isEnabled(this)) { - FirestoreSyncManager.pushAllLocalData(this) + ioSafe { FirestoreSyncManager.pushAllLocalData(this@MainActivity) } } afterPluginsLoadedEvent += ::onAllPluginsLoaded setActivityInstance(this) @@ -646,7 +651,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa override fun onPause() { super.onPause() if (FirestoreSyncManager.isEnabled(this)) { - FirestoreSyncManager.pushAllLocalData(this) + ioSafe { FirestoreSyncManager.pushAllLocalData(this@MainActivity) } } // Start any delayed updates if (ApkInstaller.delayedInstaller?.startInstallation() == true) { @@ -835,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) } } @@ -1206,10 +1214,20 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa 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() @@ -1367,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. @@ -1376,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 @@ -2053,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 7c8e1e4da8d..cf093983ba5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -115,12 +115,12 @@ object PluginManager { /** * Store data about the plugin for fetching later * */ - fun getPluginsOnline(): Array { - return (getKey>(PLUGINS_KEY) ?: emptyArray()).filter { !it.isDeleted }.toTypedArray() + fun getPluginsOnline(includeDeleted: Boolean = false): Array { + return (getKey>(PLUGINS_KEY) ?: emptyArray()).filter { includeDeleted || !it.isDeleted }.toTypedArray() } // Helper for internal use to preserve tombstones - private fun getPluginsOnlineRaw(): Array { + fun getPluginsOnlineRaw(): Array { return getKey>(PLUGINS_KEY) ?: emptyArray() } @@ -134,11 +134,12 @@ object PluginManager { // 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()) - setKey(PLUGINS_KEY, newPlugins) + 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) } } } @@ -154,8 +155,8 @@ object PluginManager { } 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) } } } @@ -175,7 +176,7 @@ object PluginManager { // Wait, removeRepository calls PluginManager.deleteRepositoryData(file.absolutePath) // It also deletes the directory. // So we just need to update the Key. - setKey(PLUGINS_KEY, newPlugins) + setKey(PLUGINS_KEY, newPlugins.toTypedArray(), commit = true) } } @@ -195,8 +196,8 @@ object PluginManager { - 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 = @@ -223,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 @@ -322,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) @@ -471,6 +460,7 @@ object PluginManager { // } Log.i(TAG, "Plugin download done!") + loadedOnlinePlugins = true } @Throws @@ -492,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 } /** @@ -553,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 } } @@ -570,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) @@ -594,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) @@ -666,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") @@ -799,7 +837,7 @@ object PluginManager { val data = PluginData( internalName, pluginUrl, - false, // Mark as local so it updates PLUGINS_KEY_LOCAL immediately + true, // Online plugin from repo, stored in PLUGINS_KEY for loading on restart newFile.absolutePath, PLUGIN_VERSION_NOT_SET, System.currentTimeMillis() @@ -876,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/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/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/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 158ed8fd7de..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 @@ -43,7 +43,7 @@ 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.DataStore +import com.lagradost.cloudstream3.utils.getFolderName import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding @@ -249,7 +249,7 @@ class DownloadFragment : BaseFragment( DOWNLOAD_ACTION_GO_TO_CHILD -> { if (click.data.type.isEpisodeBased()) { val folder = - DataStore.getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString()) + getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString()) activity?.navigate( R.id.action_navigation_downloads_to_navigation_download_child, DownloadChildFragment.newInstance(click.data.name, folder) 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 4496eb7cb43..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,7 +1,7 @@ package com.lagradost.cloudstream3.ui.download import android.content.Context -import com.lagradost.cloudstream3.utils.DataStore +import com.lagradost.cloudstream3.utils.getFolderName import android.content.DialogInterface import android.os.Environment import android.os.StatFs @@ -202,7 +202,7 @@ class DownloadViewModel : ViewModel() { val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey( DOWNLOAD_EPISODE_CACHE, - DataStore.getFolderName(it.id.toString(), it.id.toString()) + getFolderName(it.id.toString(), it.id.toString()) ) VisualDownloadCached.Header( 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 507b518a127..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,8 +1,12 @@ package com.lagradost.cloudstream3.ui.result import android.app.Activity -import com.lagradost.cloudstream3.utils.DataStore +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 @@ -59,6 +63,8 @@ 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.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 @@ -757,7 +763,7 @@ class ResultViewModel2 : ViewModel() { ) setKey( - DataStore.getFolderName( + getFolderName( DOWNLOAD_EPISODE_CACHE, parentId.toString() ), // 3 deep folder for faster acess @@ -1462,15 +1468,15 @@ 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( - DataStore.getFolderName("$currentAccount/$VIDEO_WATCH_STATE", it), + editor.setKeyRaw( + getFolderName("$currentAccount/$VIDEO_WATCH_STATE", it), watchStateString ) } @@ -1724,7 +1730,7 @@ class ResultViewModel2 : ViewModel() { } ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE -> ioSafe { - val editor = context?.let { it1 -> DataStore.editor(it1, false) } + val editor = context?.let { com.lagradost.cloudstream3.utils.editor(it, false) } if (editor != null) { val (clickSeason, clickEpisode) = click.data.let { 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 dea648af215..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 @@ -118,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) @@ -133,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()) } @@ -397,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) } } @@ -469,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) { @@ -479,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) } 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 index 641407d228f..f13fa5836e3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt @@ -16,6 +16,7 @@ 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 @@ -23,12 +24,28 @@ 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) { - // No special layout fixes needed currently + // Fix for TV: ensure items are focusable } override fun onResume() { @@ -53,20 +70,21 @@ class SyncSettingsFragment : BaseFragment( } binding.syncNowBtn.setOnClickListener { - showToast("Syncing...") - FirestoreSyncManager.syncNow(requireContext()) + val ctx = context ?: return@setOnClickListener + showToast(txt(R.string.sync_syncing)) + FirestoreSyncManager.syncNow(ctx) view?.postDelayed({ updateUI() }, 1000) } - binding.syncCopyLogsBtn.setOnClickListener { + /*binding.syncCopyLogsBtn.setOnClickListener { val logs = FirestoreSyncManager.getLogs() if (logs.isBlank()) { - showToast("No logs available yet.") + showToast(txt(R.string.sync_no_logs)) } else { - clipboardHelper(txt("Sync Logs"), logs) - showToast("Logs copied to clipboard") + clipboardHelper(txt(R.string.sync_logs_label), logs) + showToast(txt(R.string.sync_logs_copied)) } - } + }*/ // Toggle Database Config Visibility binding.syncConfigHeader.setOnClickListener { @@ -78,17 +96,23 @@ class SyncSettingsFragment : BaseFragment( } // Auto-expand if not enabled/connected - val isEnabled = FirestoreSyncManager.isEnabled(requireContext()) + 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 = requireContext() + 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) ?: "") @@ -109,86 +133,92 @@ class SyncSettingsFragment : BaseFragment( } private fun setupAuthActions(binding: FragmentSyncSettingsBinding) { - binding.syncLoginRegisterBtn.setOnClickListener { - val email = binding.syncEmailInput.text?.toString()?.trim() ?: "" - val pass = binding.syncPasswordInput.text?.toString()?.trim() ?: "" - - if (email.isBlank() || pass.length < 6) { - showToast("Please enter email and password (min 6 chars).", 1) - return@setOnClickListener - } - - binding.syncLoginRegisterBtn.isEnabled = false - binding.syncLoginRegisterBtn.text = "Authenticating..." - - FirestoreSyncManager.loginOrRegister(email, pass) { success, msg -> - main { - binding.syncLoginRegisterBtn.isEnabled = true - binding.syncLoginRegisterBtn.text = "Login / Register" - - if (success) { - showToast("Authenticated successfully!", 0) - updateUI() - } else { - showToast("Auth failed: $msg", 1) - } - } - } - } - - binding.syncLogoutBtn.setOnClickListener { - FirestoreSyncManager.logout(requireContext()) - updateUI() + // 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("Installing all pending plugins...") + showToast(txt(R.string.sync_installing_plugins)) ioSafe { - FirestoreSyncManager.installAllPending(requireActivity()) + 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(requireContext()) + FirestoreSyncManager.ignoreAllPendingPlugins(ctx) updateUI() - showToast("Pending list cleared and ignored.") + showToast(txt(R.string.sync_pending_cleared)) } } private fun setupGranularToggles(binding: FragmentSyncSettingsBinding) { binding.apply { - setupGranularToggle(syncAppearanceLayout, FirestoreSyncManager.SYNC_SETTING_APPEARANCE, "Appearance", "Sync theme, colors, and layout preferences.") - setupGranularToggle(syncPlayerLayout, FirestoreSyncManager.SYNC_SETTING_PLAYER, "Player Settings", "Sync subtitle styles, player gestures, and video quality.") - setupGranularToggle(syncDownloadsLayout, FirestoreSyncManager.SYNC_SETTING_DOWNLOADS, "Downloads", "Sync download paths and parallel download limits.") - setupGranularToggle(syncGeneralLayout, FirestoreSyncManager.SYNC_SETTING_GENERAL, "General Settings", "Sync miscellaneous app-wide preferences.") + 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)) + )) - setupGranularToggle(syncAccountsLayout, FirestoreSyncManager.SYNC_SETTING_ACCOUNTS, "User Profiles", "Sync profile names, avatars, and linked accounts.") - setupGranularToggle(syncBookmarksLayout, FirestoreSyncManager.SYNC_SETTING_BOOKMARKS, "Bookmarks", "Sync your watchlist and favorite items.") - setupGranularToggle(syncResumeWatchingLayout, FirestoreSyncManager.SYNC_SETTING_RESUME_WATCHING, "Watch Progress", "Sync where you left off on every movie/episode.") + 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)) + )) - setupGranularToggle(syncRepositoriesLayout, FirestoreSyncManager.SYNC_SETTING_REPOSITORIES, "Source Repositories", "Sync the list of added plugin repositories.") - setupGranularToggle(syncPluginsLayout, FirestoreSyncManager.SYNC_SETTING_PLUGINS, "Installed Plugins", "Sync which online plugins are installed.") + 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)) + )) - setupGranularToggle(syncHomepageLayout, FirestoreSyncManager.SYNC_SETTING_HOMEPAGE_API, "Home Provider", "Sync which homepage source is currently active.") - setupGranularToggle(syncPinnedLayout, FirestoreSyncManager.SYNC_SETTING_PINNED_PROVIDERS, "Pinned Providers", "Sync your pinned providers on the home screen.") + 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 setupGranularToggle(row: com.lagradost.cloudstream3.databinding.SyncItemRowBinding, key: String, title: String, desc: String) { - row.syncItemTitle.text = title - row.syncItemDesc.text = desc - val current = requireContext().getKey(key, true) ?: true - row.syncItemSwitch.isChecked = current - - row.syncItemSwitch.setOnCheckedChangeListener { _, isChecked -> - requireContext().setKey(key, isChecked) - } + 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() ?: "", @@ -196,8 +226,9 @@ class SyncSettingsFragment : BaseFragment( appId = binding.syncAppId.text?.toString() ?: "" ) - FirestoreSyncManager.initialize(requireContext(), config) - showToast("Connecting...") + val ctx = context ?: return + FirestoreSyncManager.initialize(ctx, config) + showToast(txt(R.string.sync_connecting)) view?.postDelayed({ updateUI() }, 1500) } @@ -218,126 +249,154 @@ class SyncSettingsFragment : BaseFragment( if (enabled) { if (isLogged) { - binding.syncStatusText.text = "Connected" - binding.syncStatusText.setTextColor(Color.parseColor("#4CAF50")) // Green + binding.syncStatusText.text = getString(R.string.sync_status_connected) + binding.syncStatusText.setTextColor(context.getColor(R.color.sync_status_connected)) } else if (isOnline) { - // Connected to DB but not logged in - binding.syncStatusText.text = "Login Needed" - binding.syncStatusText.setTextColor(Color.parseColor("#FFC107")) // Amber/Yellow + // Connected to DB but not logged in + 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 - if (error != null) { - binding.syncStatusText.text = "Error: $error" - } else { - binding.syncStatusText.text = "Disconnected" - } - binding.syncStatusText.setTextColor(Color.parseColor("#F44336")) // Red + val error = FirestoreSyncManager.lastInitError + if (error != null) { + binding.syncStatusText.text = getString(R.string.sync_status_error_prefix, error) + } else { + binding.syncStatusText.text = getString(R.string.sync_status_disconnected) + } + binding.syncStatusText.setTextColor(context.getColor(R.color.sync_status_error)) } val lastSync = FirestoreSyncManager.getLastSyncTime(context) - if (lastSync != null) { - val sdf = SimpleDateFormat("MMM dd, HH:mm:ss", Locale.getDefault()) - binding.syncLastTime.text = sdf.format(Date(lastSync)) - } else { - binding.syncLastTime.text = "Never" - } + binding.syncLastTime.text = lastSync?.let { + SimpleDateFormat("MMM dd, HH:mm:ss", Locale.getDefault()).format(Date(it)) + } ?: getString(R.string.sync_never) } else { - binding.syncConnectBtn.text = "Connect Database" + binding.syncConnectBtn.text = getString(R.string.sync_connect_database) } // 2. Auth State if (isLogged) { - val email = FirestoreSyncManager.getUserEmail() ?: "Unknown User" - binding.syncAccountStatus.text = "Signed in as: $email" - binding.syncAccountStatus.setTextColor(Color.parseColor("#4CAF50")) // Green - binding.syncAuthInputContainer.isVisible = false - binding.syncLogoutBtn.isVisible = true - + 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) + // Show content sections binding.syncAppSettingsCard.isVisible = true binding.syncLibraryCard.isVisible = true binding.syncExtensionsCard.isVisible = true binding.syncInterfaceCard.isVisible = true } else { - binding.syncAccountStatus.text = "Not Logged In" - binding.syncAccountStatus.setTextColor(Color.parseColor("#F44336")) // Red - binding.syncAuthInputContainer.isVisible = true - binding.syncLogoutBtn.isVisible = false - + 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) + // Hide content sections (require login) binding.syncAppSettingsCard.isVisible = false binding.syncLibraryCard.isVisible = false binding.syncExtensionsCard.isVisible = false binding.syncInterfaceCard.isVisible = false } - + // 3. Pending Plugins val pendingPlugins = FirestoreSyncManager.getPendingPlugins(context) - if (pendingPlugins.isNotEmpty() && isLogged) { + + if (pendingPlugins.isNotEmpty() && enabled) { binding.syncPendingPluginsCard.isVisible = true - binding.syncPendingPluginsList.removeAllViews() - // Update Header with Count - binding.syncPendingTitle.text = "New Plugins Detected (${pendingPlugins.size})" - binding.syncPendingTitle.setOnLongClickListener { + binding.syncPendingTitle.text = getString(R.string.sync_new_plugins_detected, pendingPlugins.size) + /*binding.syncPendingTitle.setOnLongClickListener { com.google.android.material.dialog.MaterialAlertDialogBuilder(context) - .setTitle("Sync Debug Info") + .setTitle(getString(R.string.sync_debug_info_title)) .setMessage(FirestoreSyncManager.lastSyncDebugInfo) - .setPositiveButton("OK", null) + .setPositiveButton(getString(R.string.ok), null) .show() true + }*/ + + 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() + } + } } - - pendingPlugins.forEach { plugin -> - val itemLayout = LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - setPadding(0, 10, 0, 10) - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - } - - val nameView = TextView(context).apply { - text = plugin.internalName - textSize = 16f - setTextColor(Color.WHITE) // TODO: Get attr color - layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) - } - - // Install Button (Small) - val installBtn = com.google.android.material.button.MaterialButton(context).apply { - text = "Install" - textSize = 12f - setOnClickListener { - ioSafe { - val success = FirestoreSyncManager.installPendingPlugin(requireActivity(), plugin) - main { - if(success) updateUI() - } - } - } - } - - // Dismiss Button (Small, Red) - val dismissBtn = com.google.android.material.button.MaterialButton(context).apply { - text = "X" - textSize = 12f - setBackgroundColor(Color.TRANSPARENT) - setTextColor(Color.RED) - setOnClickListener { - FirestoreSyncManager.ignorePendingPlugin(context, plugin) - updateUI() - } - } - - itemLayout.addView(nameView) - itemLayout.addView(dismissBtn) - itemLayout.addView(installBtn) - binding.syncPendingPluginsList.addView(itemLayout) - } + 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/SetupFragmentSync.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentSync.kt index b73a44154d4..ae57a56ce39 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentSync.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentSync.kt @@ -27,12 +27,12 @@ class SetupFragmentSync : BaseFragment( val context = context ?: return if (FirestoreSyncManager.isLogged()) { - binding.syncDescriptionText.text = "Account Connected!\nYou are ready to sync." + 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 = "With Firebase SYNC, you can sync all your settings with your other devices." + binding.syncDescriptionText.text = getString(R.string.sync_setup_welcome_desc) binding.syncYesBtt.isVisible = true } } 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 5cadcbfe371..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,7 +1,9 @@ package com.lagradost.cloudstream3.utils import android.content.Context -import com.lagradost.cloudstream3.utils.DataStore +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 @@ -185,7 +187,7 @@ object BackupUtils { fileStream = stream.openNew() printStream = PrintWriter(fileStream) - printStream.print(DataStore.mapper.writeValueAsString(backupFile)) + printStream.print(mapper.writeValueAsString(backupFile)) showToast( R.string.backup_success, @@ -231,7 +233,7 @@ object BackupUtils { ?: return@ioSafe val restoredValue = - DataStore.mapper.readValue(input, BackupFile::class.java) + 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 1a6e9832355..73379d93119 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -51,58 +51,40 @@ 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() - fun getPreferences(context: Context): SharedPreferences { return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) } - - fun getFolderName(folder: String, path: String): String { - return "${folder}/${path}" - } - - fun editor(context: Context, isEditingAppSettings: Boolean = false): Editor { - val editor: SharedPreferences.Editor = - if (isEditingAppSettings) context.getDefaultSharedPrefs() - .edit() else context.getSharedPrefs().edit() - return Editor(editor) - } } + // Top-level extension functions fun Context.getSharedPrefs(): SharedPreferences { @@ -118,11 +100,11 @@ fun Context.getKeys(folder: String): List { } fun Context.removeKey(folder: String, path: String) { - removeKey(DataStore.getFolderName(folder, path)) + removeKey(getFolderName(folder, path)) } fun Context.containsKey(folder: String, path: String): Boolean { - return containsKey(DataStore.getFolderName(folder, path)) + return containsKey(getFolderName(folder, path)) } fun Context.containsKey(path: String): Boolean { @@ -162,13 +144,13 @@ fun Context.removeKeys(folder: String): Int { } } -fun Context.setKey(path: String, value: T) { +fun Context.setKey(path: String, value: T, commit: Boolean = false) { try { - val json = DataStore.mapper.writeValueAsString(value) + val json = mapper.writeValueAsString(value) val current = getSharedPrefs().getString(path, null) if (current == json) return - getSharedPrefs().edit { + getSharedPrefs().edit(commit = commit) { putString(path, json) } // Hook for Sync: Write @@ -179,11 +161,11 @@ fun Context.setKey(path: String, value: T) { } // Internal local set without sync hook (used by sync manager to avoid loops) -fun Context.setKeyLocal(path: String, value: T) { +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 DataStore.mapper.writeValueAsString(value) - getSharedPrefs().edit { + val stringValue = if (value is String) value else mapper.writeValueAsString(value) + getSharedPrefs().edit(commit = commit) { putString(path, stringValue) } } catch (e: Exception) { @@ -191,8 +173,8 @@ fun Context.setKeyLocal(path: String, value: T) { } } -fun Context.setKeyLocal(folder: String, path: String, value: T) { - setKeyLocal(DataStore.getFolderName(folder, path), value) +fun Context.setKeyLocal(folder: String, path: String, value: T, commit: Boolean = false) { + setKeyLocal(getFolderName(folder, path), value, commit) } fun Context.removeKeyLocal(path: String) { @@ -216,16 +198,16 @@ fun Context.getKey(path: String, valueType: Class): T? { } } -fun Context.setKey(folder: String, path: String, value: T) { - setKey(DataStore.getFolderName(folder, path), value) +fun Context.setKey(folder: String, path: String, value: T, commit: Boolean = false) { + setKey(getFolderName(folder, path), value, commit) } inline fun String.toKotlinObject(): T { - return DataStore.mapper.readValue(this, T::class.java) + return mapper.readValue(this, T::class.java) } fun String.toKotlinObject(valueType: Class): T { - return DataStore.mapper.readValue(this, valueType) + return mapper.readValue(this, valueType) } // GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR @@ -274,9 +256,9 @@ inline fun Context.getKey(path: String): T? { } inline fun Context.getKey(folder: String, path: String): T? { - return getKey(DataStore.getFolderName(folder, path), null) + return getKey(getFolderName(folder, path), null) } inline fun Context.getKey(folder: String, path: String, defVal: T?): T? { - return getKey(DataStore.getFolderName(folder, path), defVal) ?: defVal + 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 3d1b9f67816..2a37d46929c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -44,7 +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_DELETED = "result_resume_watching_deleted" + 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" @@ -495,12 +495,7 @@ object DataStoreHelper { } } - fun getAllResumeStateDeletionIds(): List? { - val folder = "$currentAccount/$RESULT_RESUME_WATCHING_DELETED" - return getKeys(folder)?.mapNotNull { - it.removePrefix("$folder/").toIntOrNull() - } - } + private fun getAllResumeStateIdsOld(): List? { val folder = "$currentAccount/$RESULT_RESUME_WATCHING_OLD" @@ -550,8 +545,6 @@ object DataStoreHelper { isFromDownload ) ) - // Remove tombstone if it exists (Re-vivification) - removeKey("$currentAccount/$RESULT_RESUME_WATCHING_DELETED", parentId.toString()) } private fun removeLastWatchedOld(parentId: Int?) { @@ -562,19 +555,9 @@ object DataStoreHelper { fun removeLastWatched(parentId: Int?) { if (parentId == null) return removeKey("$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString()) - // Set tombstone - setKey("$currentAccount/$RESULT_RESUME_WATCHING_DELETED", parentId.toString(), System.currentTimeMillis()) } - fun setLastWatchedDeletionTime(parentId: Int?, time: Long) { - if (parentId == null) return - setKey("$currentAccount/$RESULT_RESUME_WATCHING_DELETED", parentId.toString(), time) - } - fun getLastWatchedDeletionTime(parentId: Int?): Long? { - if (parentId == null) return null - return getKey("$currentAccount/$RESULT_RESUME_WATCHING_DELETED", parentId.toString(), null) - } fun getLastWatched(id: Int?): VideoDownloadHelper.ResumeWatching? { if (id == null) return null @@ -805,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/FirestoreSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt index e04fbf779b8..d69351beea0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt @@ -7,19 +7,13 @@ 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.FieldValue 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.DataStore -import com.lagradost.cloudstream3.utils.getDefaultSharedPrefs -import com.lagradost.cloudstream3.utils.getSharedPrefs -import com.lagradost.cloudstream3.utils.getKeys -import com.lagradost.cloudstream3.utils.setKey -import com.lagradost.cloudstream3.utils.setKeyLocal -import com.lagradost.cloudstream3.utils.removeKey +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 @@ -27,21 +21,15 @@ 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.utils.VideoDownloadHelper -import kotlin.math.max import com.lagradost.cloudstream3.CommonActivity -import com.lagradost.cloudstream3.AutoDownloadMode -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.utils.AppContextUtils.isNetworkAvailable -import androidx.core.content.edit +import com.lagradost.cloudstream3.plugins.PluginData import kotlinx.coroutines.* -import java.util.concurrent.atomic.AtomicBoolean -import java.util.Date +import java.io.File import java.text.SimpleDateFormat +import java.util.Date import java.util.Locale -import com.lagradost.cloudstream3.plugins.PluginData -import java.io.File import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean /** * Manages Firebase Firestore synchronization with generic tombstone support and Auth. @@ -54,33 +42,24 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { // 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() - private val syncLogs = mutableListOf() var lastInitError: String? = null private set - - var lastSyncDebugInfo: String = "No sync recorded yet." - private set - fun getLogs(): String { - return syncLogs.joinToString("\n") - } - - private fun log(message: String) { - val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) - val entry = "[${sdf.format(Date())}] $message" - syncLogs.add(entry) - if (syncLogs.size > 100) syncLogs.removeAt(0) - Log.d(TAG, entry) - } + 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" @@ -121,76 +100,45 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { // --- Auth Public API --- - fun getUserEmail(): String? = auth?.currentUser?.email + // Auth is now handled by AccountManager (FirebaseApi) + // We just listen to the state in initialize() + fun isLogged(): Boolean = auth?.currentUser != null - fun login(email: String, pass: String, callback: (Boolean, String?) -> Unit) { - val currentAuth = auth ?: return callback(false, "Firebase not initialized") - currentAuth.signInWithEmailAndPassword(email, pass) - .addOnSuccessListener { callback(true, null) } - .addOnFailureListener { callback(false, it.message) } - } + fun getUserEmail(): String? = auth?.currentUser?.email - fun register(email: String, pass: String, callback: (Boolean, String?) -> Unit) { - val currentAuth = auth ?: return callback(false, "Firebase not initialized") - currentAuth.createUserWithEmailAndPassword(email, pass) - .addOnSuccessListener { callback(true, null) } - .addOnFailureListener { callback(false, it.message) } + fun getFirebaseAuth(): FirebaseAuth { + return auth ?: FirebaseAuth.getInstance() } - fun loginOrRegister(email: String, pass: String, callback: (Boolean, String?) -> Unit) { - login(email, pass) { success, msg -> - if (success) { - callback(true, null) - } else { - // Check if error implies user not found, or just try registering - // Simple approach: Try registering if login fails - log("Login failed, trying registration... ($msg)") - register(email, pass) { regSuccess, regMsg -> - if (regSuccess) { - callback(true, null) - } else { - // Return the login error if registration also fails, or a combined message - callback(false, "Login: $msg | Register: $regMsg") - } - } - } - } - } - - fun logout(context: Context) { - auth?.signOut() - // Clear local timestamps to force re-sync on next login - context.getSharedPreferences(TIMESTAMPS_PREF, Context.MODE_PRIVATE).edit().clear().apply() - log("Logged out.") - } // --- Initialization --- override fun onStop(owner: androidx.lifecycle.LifecycleOwner) { super.onStop(owner) - // Ensure pending writes are flushed immediately - // Do NOT call pushAllLocalData() as it refreshes timestamps for all keys, reviving deleted items (zombies) - scope.launch { - flushBatch() + val ctx = appContext ?: return + if (isEnabled(ctx)) { + scope.launch { + flushBatch() + } } } fun isEnabled(context: Context): Boolean { - // Use getKey to handle potential JSON string format from DataStore 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 (e: Exception) { } + } catch (_: Exception) { } } if (!isEnabled(context)) return - // Use getKey to clean up any JSON quotes around the string values val config = SyncConfig( apiKey = context.getKey(FIREBASE_API_KEY) ?: "", projectId = context.getKey(FIREBASE_PROJECT_ID) ?: "", @@ -204,59 +152,82 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { fun initialize(context: Context, config: SyncConfig) { if (isInitializing.getAndSet(true)) return + appContext = context.applicationContext scope.launch { - 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 (e: Exception) { - FirebaseApp.initializeApp(context, options, appName) - } + initializeInternal(context, config) + } + } - 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) + 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) + } - log("Firebase initialized. Waiting for User...") - - // Auth State Listener - auth?.addAuthStateListener { firebaseAuth -> - val user = firebaseAuth.currentUser - if (user != null) { - log("User signed in: ${user.email}") - setupRealtimeListener(context, user.uid) - } else { - log("User signed out.") - // Detach listeners if any? (Firestore handles this mostly) - } + 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) } - - } catch (e: Exception) { - lastInitError = e.message - log("Init Error: ${e.message}") - } finally { - isInitializing.set(false) + // 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) { - db?.collection(SYNC_COLLECTION)?.document(uid)?.addSnapshotListener { snapshot, e -> + syncListener?.remove() + syncListener = db?.collection(SYNC_COLLECTION)?.document(uid)?.addSnapshotListener { snapshot, e -> if (e != null) { - log("Listen error: ${e.message}") + // log("Listen error: ${e.message}") return@addSnapshotListener } if (snapshot != null && snapshot.exists()) { @@ -265,8 +236,13 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } } else { // New user / empty doc -> Push local - log("Empty remote doc, pushing local data.") - pushAllLocalData(context, immediate = true) + // Only allow initialization if the scan is actually done + if (isPluginManagerReady()) { + isPluginsInitialized = true + scope.launch { + pushAllLocalData(context, immediate = true) + } + } } } } @@ -275,9 +251,9 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { // Local Timestamp Management private fun setLocalTimestamp(context: Context, key: String, timestamp: Long) { - context.getSharedPreferences(TIMESTAMPS_PREF, Context.MODE_PRIVATE).edit { - putLong(key, timestamp) - } + context.getSharedPreferences(TIMESTAMPS_PREF, Context.MODE_PRIVATE).edit() + .putLong(key, timestamp) + .apply() } private fun getLocalTimestamp(context: Context, key: String): Long { @@ -285,98 +261,139 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } // Push: Write (Update or Create) + // Called from DataStore.setKey without context parameter fun pushWrite(key: String, value: Any?) { - if (isInternalKey(key)) return + val ctx = appContext ?: return + if (!isEnabled(ctx)) return + if (isApplyingRemoteData) return - // Intercept Plugin Check - if (key == PLUGINS_KEY_LOCAL) { - val json = value as? String ?: return - // Don't push raw local list. Merge it. - // We need context... but pushWrite doesn't have it. - // However, strictly speaking, we just need the value to merge into our cache. - updatePluginList(null, json) + // 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 - // Debounce/Throttle handled by simple map for now to avoid spam throttleBatch[key] = value - // We will flush this batch periodically or via pushAllLocalData - // For immediate "pushData" calls from DataStore, we can just trigger a flush job 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 Local List changes (Install/Uninstall) OR when we want to push specific updates - private fun updatePluginList(context: Context?, localJson: String?) { - scope.launch { - val localList = if (localJson != null) { - try { - parseJson>(localJson).toList() - } catch(e:Exception) { emptyList() } - } else { - emptyList() - } - - // 1. Merge Local into Cached Remote - // Rule: If it exists in Local, it exists in Remote (Active). - // We do NOT remove things from Remote just because they are missing in Local (other devices). - - var changed = false - - localList.forEach { local -> - val existingIndex = cachedRemotePlugins.indexOfFirst { isMatchingPlugin(it, local) } - if (existingIndex != -1) { - val existing = cachedRemotePlugins[existingIndex] - if (existing.isDeleted) { - // Reactivating a deleted plugin - cachedRemotePlugins[existingIndex] = existing.copy(isDeleted = false, version = local.version) - changed = true - } - // Else: matched and active. Update version? - } else { - // New plugin from local - cachedRemotePlugins.add(local.copy(isOnline = true, isDeleted = false)) - changed = true - } - } - - if (changed) { - // Push the MASTER LIST to PLUGINS_KEY - // Note: We deliberately write to PLUGINS_KEY (the shared one), not PLUGINS_KEY_LOCAL - pushWriteDirect(PLUGINS_KEY, cachedRemotePlugins.toJson()) - } - } + // 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.") + } } - fun notifyPluginDeleted(internalName: String) { - scope.launch { - 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.") - pushWriteDirect(PLUGINS_KEY, cachedRemotePlugins.toJson()) + 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 } - } else { - // Deleting something we didn't even know about? - log("Warning: Deleting unknown plugin $internalName") + 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) { - // Generic tombstone value + 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() } @@ -390,8 +407,9 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } } - private fun flushBatch() { + private suspend fun flushBatch() { val uid = auth?.currentUser?.uid ?: return + val ctx = appContext ?: return val updates = mutableMapOf() val now = System.currentTimeMillis() @@ -412,56 +430,85 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } updates["last_sync"] = now + ctx.setKey(FIREBASE_LAST_SYNC, now) db?.collection(SYNC_COLLECTION)?.document(uid) ?.set(updates, SetOptions.merge()) - ?.addOnSuccessListener { log("Flushed ${currentBatch.size} keys.") } - ?.addOnFailureListener { e -> - log("Flush failed: ${e.message}") - // Restore headers? Simplification: Ignore failure for now, expensive to retry - } } - - private fun applyRemoteData(context: Context, snapshot: DocumentSnapshot) { + private suspend fun applyRemoteData(context: Context, snapshot: DocumentSnapshot) { + if (isApplyingRemoteData) return + isApplyingRemoteData = true + try { val remoteMap = snapshot.data ?: return - val currentUid = auth?.currentUser?.uid ?: return - - log("Applying remote data (${remoteMap.size} keys)") - - remoteMap.forEach { (key, rawPayload) -> - if (key == "last_sync") return@forEach - try { - // generic parsing - // Firestore stores generic maps as Map - if (rawPayload !is Map<*, *>) return@forEach - - // manual mapping to SyncPayload - 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) { - // Remote is newer - applyPayload(context, key, v, d) - setLocalTimestamp(context, key, t) - - // Check for Continue Watching updates and trigger UI refresh - if (key.contains("result_resume_watching")) { - com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread { - MainActivity.syncUpdatedEvent.invoke(true) + // log("Applying remote data (${remoteMap.size} keys)") + + // CRITICAL FIX: Always hydrate cachedRemotePlugins from snapshot regardless of timestamp + // This ensures we know the cloud state even if we have fresh local data preserving timestamps + if (remoteMap.containsKey(FIREBASE_PLUGINS_KEY)) { + try { + val rawPayload = remoteMap[FIREBASE_PLUGINS_KEY] + if (rawPayload is Map<*, *>) { + val v = rawPayload["v"] as? String // JSON string + if (v != null) { + cachedRemotePlugins = parseJson>(v).toMutableList() + // Force process to calculate pending list + handleRemotePlugins(context, v) } + } + } catch (e: Exception) { + // log("Sync: Failed to hydrate cache: ${e.message}") + } + } else { + // log("Sync: Snapshot missing $FIREBASE_PLUGINS_KEY") + } + + remoteMap.forEach { (key, rawPayload) -> + if (key == "last_sync") return@forEach + if (key == FIREBASE_PLUGINS_KEY) return@forEach // Handled above + + 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) { + // Remote is newer + applyPayload(context, key, v, d) + setLocalTimestamp(context, key, t) } + } catch (e: Exception) { + // log("Sync: Error parsing key $key: ${e.message}") } - } catch (e: Exception) { - log("Error parsing key $key: ${e.message}") } + + val remoteSyncTime = (remoteMap["last_sync"] as? Number)?.toLong() ?: 0L + if (remoteSyncTime > 0) { + context.setKey(FIREBASE_LAST_SYNC, remoteSyncTime) + } + + // log("Sync: Finished applying remote data. New Pending Count: ${getPendingPlugins(context).size}") + + // Ensure we are marked as initialized even if no new data was applied + // But only if we are not waiting for a deferred load + if (pendingRemotePluginJson == null) { + isPluginsInitialized = true + } + + // Fire UI update event ONCE outside the loop to prevent lag + com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread { + MainActivity.syncUpdatedEvent.invoke(true) + } + } finally { + isApplyingRemoteData = false } } - // Handles the actual application of a single Key-Value-Tombstone triplet + // 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) @@ -469,33 +516,27 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } // Special Handling for Plugins (The Shared Master List) - if (key == PLUGINS_KEY) { + if (key == FIREBASE_PLUGINS_KEY) { val json = value as? String ?: return // Update Cache try { val list = parseJson>(json).toMutableList() cachedRemotePlugins = list - } catch(e:Exception) {} + } catch(_:Exception) {} // Process handleRemotePlugins(context, json) return } - // Ignore direct PLUGINS_KEY_LOCAL writes from remote (shouldn't happen with new logic, but safety) + // 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) { - // Try to serialize if it's a map? - // Our SyncPayload.v is Any? - // Firestore converts JSON objects to Maps. - // If we originally pushed a String (JSON), Firestore keeps it as String usually. - // If it became a Map, we might need to stringify it back? - // Assuming we pushed Strings mostly. context.setKeyLocal(key, value.toString()) } } @@ -503,8 +544,15 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { // --- Plugin Safety --- private fun isMatchingPlugin(p1: PluginData, local: PluginData): Boolean { - if (p1.internalName.trim().equals(local.internalName.trim(), ignoreCase = true)) return true + 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 } @@ -513,29 +561,30 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { return try { val pending = parseJson>(json).toList() val localPlugins = PluginManager.getPluginsLocal() + val onlinePlugins = PluginManager.getPluginsOnline() + val allLocal = localPlugins + onlinePlugins - pending.filter { pendingPlugin -> - localPlugins.none { local -> isMatchingPlugin(pendingPlugin, local) } + 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 { - // 1. Get all available repositories val context = activity.applicationContext val savedRepos = context.getKey>(REPOSITORIES_KEY) ?: emptyArray() val allRepos = (savedRepos + RepositoryManager.PREBUILT_REPOSITORIES).distinctBy { it.url } - // 2. Find the plugin in repositories (Network intensive!) - // Optimally we should maybe cache this, but for "Install" action it's acceptable to wait. - log("Searching repositories for ${plugin.internalName}...") + // 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...") + // log("Found in ${repo.name}. Installing...") val success = PluginManager.downloadPlugin( activity, match.second.url, @@ -551,8 +600,8 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } } - log("Could not find repository for plugin: ${plugin.internalName}") - CommonActivity.showToast(activity, "Could not find source repository for ${plugin.internalName}", 1) + // 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 } @@ -561,11 +610,10 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { val pending = getPendingPlugins(context) if (pending.isEmpty()) return - // Batch optimization: Fetch all repo plugins ONCE val savedRepos = context.getKey>(REPOSITORIES_KEY) ?: emptyArray() val allRepos = (savedRepos + RepositoryManager.PREBUILT_REPOSITORIES).distinctBy { it.url } - val onlineMap = mutableMapOf>() // InternalName -> (PluginUrl, RepoUrl) + val onlineMap = mutableMapOf>() allRepos.forEach { repo -> RepositoryManager.getRepoPlugins(repo.url)?.forEach { (repoUrl, sitePlugin) -> @@ -591,10 +639,10 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { context.setKeyLocal(PENDING_PLUGINS_KEY, remaining.toJson()) if (installedCount > 0) { - CommonActivity.showToast(activity, "Installed $installedCount plugins.", 0) + CommonActivity.showToast(activity, activity.getString(com.lagradost.cloudstream3.R.string.sync_plugins_installed, installedCount), 0) } if (remaining.isNotEmpty()) { - CommonActivity.showToast(activity, "Failed to find/install ${remaining.size} plugins.", 1) + CommonActivity.showToast(activity, activity.getString(com.lagradost.cloudstream3.R.string.sync_plugins_install_failed, remaining.size), 1) } } @@ -605,14 +653,12 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } fun ignorePendingPlugin(context: Context, plugin: PluginData) { - // Remove from pending removeFromPending(context, plugin) - // Add to ignored list val ignoredJson = context.getSharedPrefs().getString(IGNORED_PLUGINS_KEY, "[]") ?: "[]" val ignoredList = try { parseJson>(ignoredJson).toMutableSet() - } catch(e:Exception) { mutableSetOf() } + } catch(_:Exception) { mutableSetOf() } ignoredList.add(plugin.internalName) context.setKeyLocal(IGNORED_PLUGINS_KEY, ignoredList.toJson()) @@ -624,7 +670,7 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { val ignoredJson = context.getSharedPrefs().getString(IGNORED_PLUGINS_KEY, "[]") ?: "[]" val ignoredList = try { parseJson>(ignoredJson).toMutableSet() - } catch(e:Exception) { mutableSetOf() } + } catch(_:Exception) { mutableSetOf() } pending.forEach { ignoredList.add(it.internalName) } @@ -636,57 +682,44 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { private fun handleRemotePlugins(context: Context, remoteJson: String) { try { val remoteList = parseJson>(remoteJson).toList() - val remoteNames = remoteList.map { it.internalName }.toSet() - // 1. Get RAW pending list + // Baseline Update: Always track the absolute cloud state + // so our next local push is a merge, not an overwrite. + cachedRemotePlugins = remoteList.toMutableList() + + // log("Sync: Deferring plugin comparison until PluginManager is ready") + val json = context.getSharedPrefs().getString(PENDING_PLUGINS_KEY, "[]") ?: "[]" val rawPending = try { parseJson>(json).toMutableList() - } catch(e:Exception) { mutableListOf() } + } catch(_:Exception) { mutableListOf() } + val onlinePlugins = PluginManager.getPluginsOnline() val localPlugins = PluginManager.getPluginsLocal() + val installedPlugins = (localPlugins + onlinePlugins).toList() // CRITICAL: Use both local and online val ignoredJson = context.getSharedPrefs().getString(IGNORED_PLUGINS_KEY, "[]") ?: "[]" val ignoredList = try { parseJson>(ignoredJson).map { it.trim() }.toSet() - } catch(e:Exception) { emptySet() } + } catch(_:Exception) { emptySet() } var changed = false // --- PROCESS DELETIONS & INSTALLS --- remoteList.forEach { remote -> - val isLocal = localPlugins.firstOrNull { isMatchingPlugin(remote, it) } + val isLocal = installedPlugins.firstOrNull { isMatchingPlugin(remote, it) } if (remote.isDeleted) { // CASE: Deleted on Remote if (isLocal != null) { - // It is installed locally -> DELETE IT - log("Sync: Uninstalling deleted plugin ${remote.internalName}") - // We need to delete the file. PluginManager.deletePlugin(file) requires File. - // We can construct the path. + // log("Sync: Uninstalling deleted plugin ${remote.internalName}") val file = File(isLocal.filePath) if (file.exists()) { - // Run on IO - scope.launch { - // Warning: This might trigger notifyPluginDeleted, but since it's already deleted in Remote, - // the circular logic should stabilize (idempotent). - // We need a way to invoke PluginManager.deletePlugin which is a suspend function. - // Since we are in handleRemotePlugins (inside applyRemoteData -> scope.launch), we can call suspend? - // handleRemotePlugins is regular fun. We need scope. - // Actually better: Just delete the file and update key locally? - // PluginManager.deletePlugin does: delete file + unload + deletePluginData. - // It's safer to use the Manager. - // But we can't call suspend from here easily if this isn't suspend. - // Let's simplify: Just delete file and remove key. - file.delete() - file.delete() - // Update local plugin list: Remove this specific plugin, do NOT nuke the whole list - val updatedLocalPlugins = PluginManager.getPluginsLocal() - .filter { it.filePath != isLocal.filePath } - .toTypedArray() - context.setKeyLocal(PLUGINS_KEY_LOCAL, updatedLocalPlugins) - // We can't easily do full uninstall logic here without PluginManager. - // Let's post a Toast/Notification "Plugin Uninstalled via Sync"? - } + file.delete() + // Update local plugin list + val updatedLocalPlugins = PluginManager.getPluginsLocal() + .filter { it.filePath != isLocal.filePath } + .toTypedArray() + context.setKeyLocal(PLUGINS_KEY_LOCAL, updatedLocalPlugins) } } @@ -698,11 +731,8 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } else { // CASE: Active on Remote if (isLocal == null) { - // Not installed locally. - // Check if Ignored val cleanName = remote.internalName.trim() if (!ignoredList.contains(cleanName)) { - // Check if already in Pending if (rawPending.none { isMatchingPlugin(remote, it) }) { rawPending.add(remote) changed = true @@ -718,15 +748,11 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { } // --- CLEANUP PENDING --- - // Remove any pending items that are NOT in the remote list anymore? - // If Device A deleted it, it comes as isDeleted=true. - // If Device A hard-removed it (tombstone gc?), it disappears. - // If it disappears, we should probably remove it from pending. rawPending.retainAll { pending -> remoteList.any { remote -> isMatchingPlugin(remote, pending) } } - lastSyncDebugInfo = """ + /*lastSyncDebugInfo = """ Remote: ${remoteList.size} Local: ${localPlugins.size} (${localPlugins.take(3).map { it.internalName }}) Ignored: ${ignoredList.size} @@ -736,51 +762,120 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { log("Sync Debug: $lastSyncDebugInfo") if (changed) { - log("Saving updated pending plugins list. Size: ${rawPending.size}") + log("Sync: Pending plugins list updated. New size: ${rawPending.size}") context.setKeyLocal(PENDING_PLUGINS_KEY, rawPending.toJson()) + // Notify UI via event + main { MainActivity.syncUpdatedEvent.invoke(true) } + }*/ + + if (changed) { + context.setKeyLocal(PENDING_PLUGINS_KEY, rawPending.toJson()) + // Notify UI via event + main { MainActivity.syncUpdatedEvent.invoke(true) } + } + + // We need to ensure that what's in PLUGINS_KEY matches the installed plugins that are still ACTIVE on remote, + // PLUS any local online plugins we already had that aren't in the cloud yet (to avoid deletion). + 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) { + // Update existing with cloud metadata (version, url etc) + currentOnlinePlugins[match] = remote.copy( + filePath = currentOnlinePlugins[match].filePath, + isOnline = true + ) + } else { + // If it's on disk but not in our list yet, it will be added by a local scan or later. + // But if it IS in allLocalPlugins (meaning it's installed as manual), we might want to convert? + // For now, only track what we already had as online. + } + } + } + + if (remoteList.isNotEmpty()) { + context.setKeyLocal(PLUGINS_KEY, currentOnlinePlugins.toTypedArray()) } + isPluginsInitialized = true + // log("Sync: Plugins initialized from remote data.") + } catch(e:Exception) { - log("Plugin Parse Error: ${e.message}") + // log("Plugin Parse Error: ${e.message}") + // Ensure flag is set even on error to avoid blocking forever + isPluginsInitialized = true } } - // --- Helpers --- + /** + * 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 { - // Prevent syncing of internal state keys + if (key == FIREBASE_PLUGINS_KEY) return false // Explicitly allow sync list if (key.startsWith("firebase_")) return true - if (key.startsWith("firestore_")) return true // Includes IGNORED_PLUGINS_KEY + 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 } - - fun pushAllLocalData(context: Context, immediate: Boolean = false) { - if (!isLogged()) return + + suspend fun pushAllLocalData(context: Context, immediate: Boolean = false) { + if (auth?.currentUser == null) return val prefs = context.getSharedPrefs() - scope.launch { - prefs.all.forEach { (k, v) -> - if (!isInternalKey(k) && k != PLUGINS_KEY_LOCAL && v != null) { - // Normal keys - pushWrite(k, v) - } else if (k == PLUGINS_KEY_LOCAL && v != null) { - // Trigger plugin merge - val json = v as? String - if (json != null) updatePluginList(context, json) - } + prefs.all.forEach { (k, v) -> + if (!isInternalKey(k) && k != PLUGINS_KEY_LOCAL && k != PLUGINS_KEY && v != null) { + pushWrite(k, v) } - if (immediate) flushBatch() } + + // Unified Plugin Push + updatePluginList(context) + + if (immediate) flushBatch() } - + fun syncNow(context: Context) { - pushAllLocalData(context, true) + 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/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 index 7357690f07f..e64751824eb 100644 --- a/app/src/main/res/layout/fragment_setup_sync.xml +++ b/app/src/main/res/layout/fragment_setup_sync.xml @@ -11,7 +11,7 @@ android:layout_height="wrap_content" android:layout_marginTop="20dp" android:gravity="center" - android:text="Firebase Sync" + android:text="@string/sync_setup_welcome_title" android:textSize="24sp" android:textStyle="bold" /> @@ -27,7 +27,7 @@ android:id="@+id/sync_description_text" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="With Firebase SYNC, you can sync all your settings with your other devices." + android:text="@string/sync_setup_welcome_desc" android:gravity="center" android:textSize="18sp" android:layout_marginBottom="32dp"/> @@ -37,7 +37,7 @@ style="@style/WhiteButton" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="Yes, Setup Sync" + android:text="@string/sync_setup_yes_btn" android:layout_marginBottom="16dp"/> @@ -56,7 +56,7 @@ style="@style/BlackButton" android:layout_width="wrap_content" android:layout_gravity="center_vertical|end" - android:text="Next" /> + android:text="@string/next" /> diff --git a/app/src/main/res/layout/fragment_sync_settings.xml b/app/src/main/res/layout/fragment_sync_settings.xml index 6033beaf0fd..bbd7d5dd3b4 100644 --- a/app/src/main/res/layout/fragment_sync_settings.xml +++ b/app/src/main/res/layout/fragment_sync_settings.xml @@ -19,7 +19,7 @@ android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/primaryGrayBackground" - app:title="Firebase Sync" + app:title="@string/firebase_sync" app:navigationIcon="@drawable/ic_baseline_arrow_back_24" /> @@ -45,6 +45,7 @@ app:cardElevation="2dp" android:layout_marginBottom="16dp" app:cardBackgroundColor="?attr/primaryGrayBackground" + android:nextFocusDown="@id/sync_pending_plugins_card" android:visibility="gone" tools:visibility="visible"> @@ -52,12 +53,13 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:padding="16dp"> + android:padding="16dp" + android:focusable="false"> - - - - - - - - - - - - - - - + + android:text="@string/sync_manage_accounts" + style="@style/WhiteButton"/> @@ -135,6 +94,8 @@ app:cardElevation="2dp" android:layout_marginBottom="16dp" app:cardBackgroundColor="?attr/primaryGrayBackground" + android:nextFocusUp="@id/sync_account_card" + android:nextFocusDown="@id/sync_config_header" android:visibility="gone" tools:visibility="visible"> @@ -155,37 +116,41 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:text="New Plugins Detected" + android:text="@string/sync_pending_plugins_title" android:textColor="?attr/textColor" android:textSize="18sp" - android:textStyle="bold"/> + android:textStyle="bold" + android:focusable="true" + android:clickable="true" + android:background="?attr/selectableItemBackground"/> + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/sync_warn_tag" + android:textSize="10sp" + android:background="@color/red_400" + android:paddingHorizontal="4dp" + android:paddingVertical="2dp" + android:textColor="@android:color/white"/> - - - + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + tools:listitem="@layout/item_sync_plugin" + android:layout_marginBottom="12dp" + android:descendantFocusability="afterDescendants"/> + android:text="@string/sync_install_all" + android:layout_marginEnd="4dp" + style="@style/WhiteButton"/> @@ -241,7 +207,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:text="Database Configuration" + android:text="@string/sync_database_configuration" android:textColor="?attr/textColor" android:textSize="18sp" android:textStyle="bold"/> @@ -262,46 +228,52 @@ + android:inputType="textPassword" + android:nextFocusDown="@id/sync_project_id"/> + android:layout_height="wrap_content" + android:nextFocusDown="@id/sync_app_id" + android:nextFocusUp="@id/sync_api_key"/> + android:layout_height="wrap_content" + android:nextFocusDown="@id/sync_connect_btn" + android:nextFocusUp="@id/sync_project_id"/> + android:text="@string/sync_connect_database" + app:icon="@drawable/ic_baseline_cloud_queue_24" + style="@style/WhiteButton"/> @@ -315,6 +287,8 @@ app:cardElevation="2dp" android:layout_marginBottom="12dp" app:cardBackgroundColor="?attr/primaryGrayBackground" + android:nextFocusUp="@id/sync_connect_btn" + android:nextFocusDown="@id/sync_library_card" android:visibility="gone" tools:visibility="visible"> @@ -327,39 +301,43 @@ - + - + - + - + @@ -373,6 +351,8 @@ app:cardElevation="2dp" android:layout_marginBottom="12dp" app:cardBackgroundColor="?attr/primaryGrayBackground" + android:nextFocusUp="@id/sync_app_settings_card" + android:nextFocusDown="@id/sync_extensions_card" android:visibility="gone" tools:visibility="visible"> @@ -385,32 +365,35 @@ - + - + - + @@ -424,6 +407,8 @@ app:cardElevation="2dp" android:layout_marginBottom="12dp" app:cardBackgroundColor="?attr/primaryGrayBackground" + android:nextFocusUp="@id/sync_library_card" + android:nextFocusDown="@id/sync_interface_card" android:visibility="gone" tools:visibility="visible"> @@ -436,25 +421,27 @@ - + - + @@ -468,6 +455,8 @@ app:cardElevation="2dp" android:layout_marginBottom="12dp" app:cardBackgroundColor="?attr/primaryGrayBackground" + android:nextFocusUp="@id/sync_extensions_card" + android:nextFocusDown="@id/sync_status_card" android:visibility="gone" tools:visibility="visible"> @@ -480,25 +469,27 @@ - + - + @@ -512,6 +503,7 @@ app:cardElevation="2dp" android:layout_marginBottom="16dp" app:cardBackgroundColor="?attr/primaryGrayBackground" + android:nextFocusUp="@id/sync_interface_card" android:visibility="gone" tools:visibility="visible"> @@ -524,7 +516,7 @@ @@ -559,42 +551,35 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:text="Last Sync:" + android:text="@string/sync_last_sync_label" android:textColor="?attr/textColor"/> + android:orientation="horizontal" + android:gravity="center"> - + 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 2f697cf45e0..b134a2f0f51 100644 --- a/app/src/main/res/layout/main_settings.xml +++ b/app/src/main/res/layout/main_settings.xml @@ -102,6 +102,8 @@ android:nextFocusDown="@id/settings_extensions" android:text="@string/category_account" /> + + + android:paddingVertical="8dp" + android:focusable="true" + android:clickable="true" + android:background="?attr/selectableItemBackground"> #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 adc0f231deb..d8d48355dee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,9 @@ - Firebase Sync - firebase_sync_key + Firebase + Firebase + Firebase + %1$s Ep %2$d Cast: %s @@ -182,7 +184,7 @@ Search Library Accounts and Security - Firebase Sync + Firebase Updates and Backup Info Advanced Search @@ -766,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/gradle.properties b/gradle.properties index 48e0f6c7e67..900491259fe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,32 +1,24 @@ -# Project-wide Gradle settings. -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. +# Gradle settings for the project. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx4G -Dfile.encoding=UTF-8 + +# Specifies the JVM arguments used for the Gradle Daemon. +# The setting is particularly useful for configuring JVM memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 + # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app"s APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn -android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -# android.enableJetifier=true -# Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official -android.nonTransitiveRClass=true -org.gradle.caching=true -org.gradle.configuration-cache=true +# AndroidX libraries use this flag to enable Jetifier. +# Jetifier helps in migrating libraries to AndroidX. +android.useAndroidX=true +android.enableJetifier=true -# Compiling with Java 8 is deprecated but we still use it for now -android.javaCompile.suppressSourceTargetDeprecationWarning=true +# Kotlin code style for the project. +kotlin.code.style=official -# Disable path check for non-ASCII characters (e.g. 'Masaüstü') +# This line is necessary because the project path contains non-ASCII characters (e.g., 'Masaüstü'). +# Although flagged in PR reviews, it is required for building on this specific Windows environment. android.overridePathCheck=true - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6edeacf7307..47821a0b0ae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ # https://docs.gradle.org/current/userguide/dependency_versions.html#sec:strict-version [versions] activityKtx = "1.11.0" -androidGradlePlugin = "9.0.0" +androidGradlePlugin = "8.13.2" appcompat = "1.7.1" biometric = "1.4.0-alpha04" buildkonfigGradlePlugin = "0.17.1" @@ -18,7 +18,7 @@ fragmentKtx = "1.8.9" fuzzywuzzy = "1.4.0" jacksonModuleKotlin = { strictly = "2.13.1" } # Later versions don't support minSdk <26 (Crashes on Android TV's and FireSticks) json = "20251224" -firebaseBom = "33.7.0" +firebaseBom = "32.8.1" googleServices = "4.4.2" jsoup = "1.21.2" junit = "4.13.2" @@ -51,7 +51,7 @@ zipline = "1.24.0" jvmTarget = "1.8" jdkToolchain = "17" -minSdk = "23" +minSdk = "21" compileSdk = "36" targetSdk = "36" @@ -115,8 +115,7 @@ work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "w zipline = { module = "app.cash.zipline:zipline-android", version.ref = "zipline" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } firebase-firestore = { module = "com.google.firebase:firebase-firestore" } -firebase-auth = { module = "com.google.firebase:firebase-auth" } -firebase-analytics = { module = "com.google.firebase:firebase-analytics" } +firebase-auth = { group = "com.google.firebase", name = "firebase-auth-ktx" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } From e1c9b99b292f101541c6ee8de0623625ca2eed06 Mon Sep 17 00:00:00 2001 From: rappc87 Date: Sun, 15 Feb 2026 04:20:26 +0300 Subject: [PATCH 13/13] LITTLE FIXES --- .../cloudstream3/plugins/PluginManager.kt | 2 +- .../ui/settings/SyncSettingsFragment.kt | 97 +++--- .../utils/FirestoreSyncManager.kt | 276 ++++++++---------- 3 files changed, 164 insertions(+), 211 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index cf093983ba5..1381458e250 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -508,7 +508,7 @@ object PluginManager { } // Load all plugins sequentially to avoid DataStore race conditions! - (getPluginsOnline()).toList().forEach { pluginData -> + getPluginsOnline().toList().forEach { pluginData -> loadPlugin( context, File(pluginData.filePath), 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 index f13fa5836e3..fb14cd8a50e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt @@ -236,82 +236,81 @@ class SyncSettingsFragment : BaseFragment( val binding = binding ?: return val context = context ?: return - // 1. Connection Status val enabled = FirestoreSyncManager.isEnabled(context) val isOnline = FirestoreSyncManager.isOnline() val isLogged = FirestoreSyncManager.isLogged() - // Status Card + 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 - - // Account Card Visibility: Only show if enabled (connected to DB config) binding.syncAccountCard.isVisible = enabled - if (enabled) { - if (isLogged) { - binding.syncStatusText.text = getString(R.string.sync_status_connected) - binding.syncStatusText.setTextColor(context.getColor(R.color.sync_status_connected)) - } else if (isOnline) { - // Connected to DB but not logged in - 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 - if (error != null) { - binding.syncStatusText.text = getString(R.string.sync_status_error_prefix, error) - } else { - binding.syncStatusText.text = getString(R.string.sync_status_disconnected) - } - binding.syncStatusText.setTextColor(context.getColor(R.color.sync_status_error)) - } + if (!enabled) { + binding.syncConnectBtn.text = getString(R.string.sync_connect_database) + return + } - val lastSync = FirestoreSyncManager.getLastSyncTime(context) - binding.syncLastTime.text = lastSync?.let { - SimpleDateFormat("MMM dd, HH:mm:ss", Locale.getDefault()).format(Date(it)) - } ?: getString(R.string.sync_never) + 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 { - binding.syncConnectBtn.text = getString(R.string.sync_connect_database) + 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)) } - // 2. Auth State + 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) - - // Show content sections - binding.syncAppSettingsCard.isVisible = true - binding.syncLibraryCard.isVisible = true - binding.syncExtensionsCard.isVisible = true - binding.syncInterfaceCard.isVisible = true } 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) - - // Hide content sections (require login) - binding.syncAppSettingsCard.isVisible = false - binding.syncLibraryCard.isVisible = false - binding.syncExtensionsCard.isVisible = false - binding.syncInterfaceCard.isVisible = false } - // 3. Pending Plugins + 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 - // Update Header with Count binding.syncPendingTitle.text = getString(R.string.sync_new_plugins_detected, pendingPlugins.size) - /*binding.syncPendingTitle.setOnLongClickListener { - com.google.android.material.dialog.MaterialAlertDialogBuilder(context) - .setTitle(getString(R.string.sync_debug_info_title)) - .setMessage(FirestoreSyncManager.lastSyncDebugInfo) - .setPositiveButton(getString(R.string.ok), null) - .show() - true - }*/ val adapter = PluginAdapter(pendingPlugins) { plugin: PluginData, action: String -> when (action) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt index d69351beea0..450b63bb970 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt @@ -439,74 +439,62 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { if (isApplyingRemoteData) return isApplyingRemoteData = true try { - val remoteMap = snapshot.data ?: return + val remoteMap = snapshot.data ?: return - // log("Applying remote data (${remoteMap.size} keys)") - - // CRITICAL FIX: Always hydrate cachedRemotePlugins from snapshot regardless of timestamp - // This ensures we know the cloud state even if we have fresh local data preserving timestamps - if (remoteMap.containsKey(FIREBASE_PLUGINS_KEY)) { - try { - val rawPayload = remoteMap[FIREBASE_PLUGINS_KEY] - if (rawPayload is Map<*, *>) { - val v = rawPayload["v"] as? String // JSON string - if (v != null) { - cachedRemotePlugins = parseJson>(v).toMutableList() - // Force process to calculate pending list - handleRemotePlugins(context, v) - } - } - } catch (e: Exception) { - // log("Sync: Failed to hydrate cache: ${e.message}") - } - } else { - // log("Sync: Snapshot missing $FIREBASE_PLUGINS_KEY") - } - - remoteMap.forEach { (key, rawPayload) -> - if (key == "last_sync") return@forEach - if (key == FIREBASE_PLUGINS_KEY) return@forEach // Handled above - - 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) { - // Remote is newer - applyPayload(context, key, v, d) - setLocalTimestamp(context, key, t) - } - } catch (e: Exception) { - // log("Sync: Error parsing key $key: ${e.message}") - } - } + hydrateCachedPlugins(context, remoteMap) + applyRemotePayloads(context, remoteMap) val remoteSyncTime = (remoteMap["last_sync"] as? Number)?.toLong() ?: 0L if (remoteSyncTime > 0) { context.setKey(FIREBASE_LAST_SYNC, remoteSyncTime) } - // log("Sync: Finished applying remote data. New Pending Count: ${getPendingPlugins(context).size}") - - // Ensure we are marked as initialized even if no new data was applied - // But only if we are not waiting for a deferred load if (pendingRemotePluginJson == null) { isPluginsInitialized = true } - // Fire UI update event ONCE outside the loop to prevent lag - com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread { - MainActivity.syncUpdatedEvent.invoke(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) { @@ -682,133 +670,99 @@ object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { private fun handleRemotePlugins(context: Context, remoteJson: String) { try { val remoteList = parseJson>(remoteJson).toList() - - // Baseline Update: Always track the absolute cloud state - // so our next local push is a merge, not an overwrite. cachedRemotePlugins = remoteList.toMutableList() - // log("Sync: Deferring plugin comparison until PluginManager is ready") - val json = context.getSharedPrefs().getString(PENDING_PLUGINS_KEY, "[]") ?: "[]" val rawPending = try { parseJson>(json).toMutableList() } catch(_:Exception) { mutableListOf() } - val onlinePlugins = PluginManager.getPluginsOnline() - val localPlugins = PluginManager.getPluginsLocal() - val installedPlugins = (localPlugins + onlinePlugins).toList() // CRITICAL: Use both local and online - val ignoredJson = context.getSharedPrefs().getString(IGNORED_PLUGINS_KEY, "[]") ?: "[]" - val ignoredList = try { - parseJson>(ignoredJson).map { it.trim() }.toSet() - } catch(_:Exception) { emptySet() } + val installedPlugins = (PluginManager.getPluginsLocal() + PluginManager.getPluginsOnline()).toList() + val ignoredList = getIgnoredPlugins(context) - var changed = false - - // --- PROCESS DELETIONS & INSTALLS --- - remoteList.forEach { remote -> - val isLocal = installedPlugins.firstOrNull { isMatchingPlugin(remote, it) } - - if (remote.isDeleted) { - // CASE: Deleted on Remote - if (isLocal != null) { - // log("Sync: Uninstalling deleted plugin ${remote.internalName}") - val file = File(isLocal.filePath) - if (file.exists()) { - file.delete() - // Update local plugin list - val updatedLocalPlugins = PluginManager.getPluginsLocal() - .filter { it.filePath != isLocal.filePath } - .toTypedArray() - context.setKeyLocal(PLUGINS_KEY_LOCAL, updatedLocalPlugins) - } - } - - // Also remove from Pending if present - if (rawPending.removeIf { isMatchingPlugin(remote, it) }) { - changed = true - } - - } else { - // CASE: Active on Remote - if (isLocal == null) { - val cleanName = remote.internalName.trim() - if (!ignoredList.contains(cleanName)) { - if (rawPending.none { isMatchingPlugin(remote, it) }) { - rawPending.add(remote) - changed = true - } - } - } else { - // Installed locally. Ensure not in pending. - if (rawPending.removeIf { isMatchingPlugin(remote, it) }) { - changed = true - } - } - } - } - - // --- CLEANUP PENDING --- - rawPending.retainAll { pending -> - remoteList.any { remote -> isMatchingPlugin(remote, pending) } - } - - /*lastSyncDebugInfo = """ - Remote: ${remoteList.size} - Local: ${localPlugins.size} (${localPlugins.take(3).map { it.internalName }}) - Ignored: ${ignoredList.size} - Pending: ${rawPending.size} (${rawPending.take(3).map { it.internalName }}) - """.trimIndent() - - log("Sync Debug: $lastSyncDebugInfo") + val changed = processRemotePluginChanges(context, remoteList, installedPlugins, rawPending, ignoredList) + cleanupPendingPlugins(remoteList, rawPending) if (changed) { - log("Sync: Pending plugins list updated. New size: ${rawPending.size}") context.setKeyLocal(PENDING_PLUGINS_KEY, rawPending.toJson()) - // Notify UI via event - main { MainActivity.syncUpdatedEvent.invoke(true) } - }*/ - - if (changed) { - context.setKeyLocal(PENDING_PLUGINS_KEY, rawPending.toJson()) - // Notify UI via event main { MainActivity.syncUpdatedEvent.invoke(true) } } - // We need to ensure that what's in PLUGINS_KEY matches the installed plugins that are still ACTIVE on remote, - // PLUS any local online plugins we already had that aren't in the cloud yet (to avoid deletion). - 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) { - // Update existing with cloud metadata (version, url etc) - currentOnlinePlugins[match] = remote.copy( - filePath = currentOnlinePlugins[match].filePath, - isOnline = true - ) - } else { - // If it's on disk but not in our list yet, it will be added by a local scan or later. - // But if it IS in allLocalPlugins (meaning it's installed as manual), we might want to convert? - // For now, only track what we already had as online. + 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 + } } - - if (remoteList.isNotEmpty()) { - context.setKeyLocal(PLUGINS_KEY, currentOnlinePlugins.toTypedArray()) + } + 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 + ) } - - isPluginsInitialized = true - // log("Sync: Plugins initialized from remote data.") - - } catch(e:Exception) { - // log("Plugin Parse Error: ${e.message}") - // Ensure flag is set even on error to avoid blocking forever - isPluginsInitialized = true } + + if (remoteList.isNotEmpty()) { + context.setKeyLocal(PLUGINS_KEY, currentOnlinePlugins.toTypedArray()) + } + + // Signal initialized + isPluginsInitialized = true } /**