diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e8a07b571df..c3c1c310a2b 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..cb95270e743 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.syncNow(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 53d29cdb8e0..ee8cd989d61 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 @@ -62,6 +62,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 @@ -484,5 +485,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..0e42b89c45c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt @@ -0,0 +1,167 @@ +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.DataStore.setKey +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("Syncing...") + FirestoreSyncManager.pushAllLocalData(requireContext(), immediate = true) + // Brief delay to allow sync to happen then update UI + view?.postDelayed({ updateStatusUI() }, 1000) + } + + 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() + + // 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) + } + } + + 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("Initial sync started...") + view?.postDelayed({ updateStatusUI() }, 1500) + } + + 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" + } + + 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?.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/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index 20d33c11218..6c368ab66f0 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(this, 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..b1393d82598 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" @@ -473,8 +475,10 @@ object DataStoreHelper { } fun deleteAllResumeStateIds() { - val folder = "$currentAccount/$RESULT_RESUME_WATCHING" - removeKeys(folder) + val ids = getAllResumeStateIds() + ids?.forEach { id -> + removeLastWatched(id) + } } fun deleteBookmarkedData(id: Int?) { @@ -491,6 +495,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 +537,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 +546,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 +562,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 +670,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 +747,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..6f475573ae7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt @@ -0,0 +1,909 @@ +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 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" + + // 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. + return key.endsWith("/$USER_SELECTED_HOMEPAGE_API") + } + + private fun shouldSyncHomepage(context: Context): Boolean { + return shouldSync(context, SYNC_SETTING_HOMEPAGE_API) + } + + 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, isFullReload = true) + // 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, 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 -> + 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, isFullReload = false) + } + } + } + } + + /** + * 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}") + } + } + } + + // 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 var debounceJob: Job? = null + + fun pushAllLocalData(context: Context, immediate: Boolean = false) { + if (isInitializing.get()) { + log("Sync is initializing, skipping immediate push.") + return + } + + debounceJob?.cancel() + if (immediate) { + scope.launch { performPushAllLocalData(context) } + } else { + debounceJob = scope.launch { + delay(5000) // Debounce for 5 seconds + performPushAllLocalData(context) + } + } + } + + /** + * 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 (Differential, no full reload) + handleInitialSync(context, isFullReload = false) + // 2. Immediate Push + 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 + ) + + // 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 + + 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 + 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) + } + + // 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") + + (!isIgnored && syncBookmarks && value is String) + } + 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 + } + + 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) + } + + 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) { + 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 (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, + "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) { + // 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) + } + } + } + + 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) + } + } + } + + 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()) + } + } + 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}") } + } + } + + 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) + } + + 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}/$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) + } + } + } + + 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) + } + } + } + } +} 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..9808e216afb --- /dev/null +++ b/app/src/main/res/layout/fragment_sync_settings.xml @@ -0,0 +1,402 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/main_settings.xml b/app/src/main/res/layout/main_settings.xml index 4a41759e041..1ea6c63d13f 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/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index 784fc515e8f..79ba3bc7f4b 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -413,6 +413,13 @@ app:exitAnim="@anim/exit_anim" app:popEnterAnim="@anim/enter_anim" app:popExitAnim="@anim/exit_anim" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ad0ec42364..6817206bbeb 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 @@ -177,6 +179,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 bbef5f05bb9..b1cc4ffaacf 100644 --- a/app/src/main/res/xml/settings_account.xml +++ b/app/src/main/res/xml/settings_account.xml @@ -25,6 +25,11 @@ android:icon="@drawable/subdl_logo_big" android:key="@string/subdl_key" /> + +