From ddea7d5a06d2fcd9afaf94f8518da8d160e352f1 Mon Sep 17 00:00:00 2001 From: rappc87 Date: Sat, 10 Jan 2026 18:05:05 +0300 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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"> + + + + + +