diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 41e8fc0a01a..cccb15e1e25 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,8 +7,9 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { alias(libs.plugins.android.application) - alias(libs.plugins.dokka) - alias(libs.plugins.kotlin.android) + // alias(libs.plugins.dokka) + + // alias(libs.plugins.google.services) // We use manual Firebase initialization } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) @@ -217,6 +218,19 @@ dependencies { // Downloading & Networking implementation(libs.work.runtime.ktx) implementation(libs.nicehttp) // HTTP Lib + + // Firebase + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.firestore) + implementation(libs.firebase.auth) + 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. @@ -269,6 +283,7 @@ tasks.withType { } } +/* dokka { moduleName = "App" dokkaSourceSets { @@ -287,3 +302,4 @@ dokka { } } } +*/ diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt index 80f084b08f0..753f17a44e2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -2,9 +2,9 @@ package com.lagradost.cloudstream3 import android.content.Context import com.lagradost.api.setContext -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.removeKeys -import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.getKey +import com.lagradost.cloudstream3.utils.removeKeys +import com.lagradost.cloudstream3.utils.setKey import java.lang.ref.WeakReference /** diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt index b7832799884..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 1caaaa4c693..e835cf2c475 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 @@ -152,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 @@ -261,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. @@ -620,6 +629,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 +645,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 +1205,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 +1671,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..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 @@ -75,6 +76,8 @@ data class PluginData( @JsonProperty("isOnline") val isOnline: Boolean, @JsonProperty("filePath") val filePath: String, @JsonProperty("version") val version: Int, + @JsonProperty("addedDate") val addedDate: Long = 0, + @JsonProperty("isDeleted") val isDeleted: Boolean = false, ) { fun toSitePlugin(): SitePlugin { return SitePlugin( @@ -109,14 +112,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 +147,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 +162,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 +193,7 @@ object PluginManager { } - fun getPluginsOnline(): Array { - return getKey(PLUGINS_KEY) ?: emptyArray() - } + fun getPluginsLocal(): Array { return getKey(PLUGINS_KEY_LOCAL) ?: emptyArray() @@ -360,14 +386,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 +403,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)) { @@ -766,9 +799,10 @@ 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 + PLUGIN_VERSION_NOT_SET, + System.currentTimeMillis() ) return if (loadPlugin) { @@ -795,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/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt index 45ed65611e7..1d28b6bee23 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -16,6 +16,7 @@ import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.utils.FirestoreSyncManager import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.BufferedInputStream @@ -169,7 +170,8 @@ object RepositoryManager { repoLock.withLock { val currentRepos = getRepositories() // No duplicates - setKey(REPOSITORIES_KEY, (currentRepos + repository).distinctBy { it.url }) + val newRepos = (currentRepos + repository).distinctBy { it.url }.toTypedArray() + setKey(REPOSITORIES_KEY, newRepos) } } @@ -182,7 +184,7 @@ object RepositoryManager { repoLock.withLock { val currentRepos = getKey>(REPOSITORIES_KEY) ?: emptyArray() // No duplicates - val newRepos = currentRepos.filter { it.url != repository.url } + val newRepos = currentRepos.filter { it.url != repository.url }.toTypedArray() setKey(REPOSITORIES_KEY, newRepos) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/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/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index 6df5bbbef1d..1048cc2efa4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -420,6 +420,10 @@ class HomeViewModel : ViewModel() { reloadStored() } + private fun onSyncUpdated(unused: Boolean) { + loadResumeWatching() + } + private fun afterPluginsLoaded(forceReload: Boolean) { loadAndCancel(DataStoreHelper.currentHomePage, forceReload) } @@ -440,6 +444,7 @@ class HomeViewModel : ViewModel() { init { MainActivity.bookmarksUpdatedEvent += ::bookmarksUpdated + MainActivity.syncUpdatedEvent += ::onSyncUpdated MainActivity.afterPluginsLoadedEvent += ::afterPluginsLoaded MainActivity.mainPluginsLoadedEvent += ::afterMainPluginsLoaded MainActivity.reloadHomeEvent += ::reloadHome @@ -448,6 +453,7 @@ class HomeViewModel : ViewModel() { override fun onCleared() { MainActivity.bookmarksUpdatedEvent -= ::bookmarksUpdated + MainActivity.syncUpdatedEvent -= ::onSyncUpdated MainActivity.afterPluginsLoadedEvent -= ::afterPluginsLoaded MainActivity.mainPluginsLoadedEvent -= ::afterMainPluginsLoaded MainActivity.reloadHomeEvent -= ::reloadHome diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index fdcbb044cff..10b52787dd0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -386,10 +386,10 @@ class CS3IPlayer : IPlayer { ?: return } - override fun setPreferredAudioTrack(trackLanguage: String?, id: String?, formatIndex: Int?) { + override fun setPreferredAudioTrack(trackLanguage: String?, id: String?, trackIndex: Int?) { preferredAudioTrackLanguage = trackLanguage id?.let { trackId -> - val trackFormatIndex = formatIndex ?: 0 + val trackFormatIndex = trackIndex ?: 0 exoPlayer?.currentTracks?.groups ?.filter { it.type == TRACK_TYPE_AUDIO } ?.find { group -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt index 0999e9c158a..7240a90c724 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt @@ -20,6 +20,7 @@ */ package com.lagradost.cloudstream3.ui.player +import android.os.Build import android.text.Html import android.text.Spanned import android.text.TextUtils @@ -115,7 +116,12 @@ class CustomSubripParser : SubtitleParser { currentLine = parsableByteArray.readLine(charset) } - val text = Html.fromHtml(textBuilder.toString()) + val text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Html.fromHtml(textBuilder.toString(), Html.FROM_HTML_MODE_LEGACY) + } else { + @Suppress("DEPRECATION") + Html.fromHtml(textBuilder.toString()) + } var alignmentTag: String? = null for (i in tags.indices) { @@ -260,9 +266,9 @@ class CustomSubripParser : SubtitleParser { val hours = matcher.group(groupOffset + 1) var timestampMs = if (hours != null) hours.toLong() * 60 * 60 * 1000 else 0 timestampMs += - Assertions.checkNotNull(matcher.group(groupOffset + 2)) + Assertions.checkNotNull(matcher.group(groupOffset + 2)) .toLong() * 60 * 1000 - timestampMs += Assertions.checkNotNull(matcher.group(groupOffset + 3)) + timestampMs += Assertions.checkNotNull(matcher.group(groupOffset + 3)) .toLong() * 1000 val millis = matcher.group(groupOffset + 4) if (millis != null) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 4b4d0b5fadd..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/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..641407d228f --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt @@ -0,0 +1,343 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.content.res.ColorStateList +import android.graphics.Color +import android.os.Bundle +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentSyncSettingsBinding +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.utils.getKey +import com.lagradost.cloudstream3.utils.setKey +import com.lagradost.cloudstream3.utils.FirestoreSyncManager +import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper +import com.lagradost.cloudstream3.utils.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 + +class SyncSettingsFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSyncSettingsBinding::inflate) +) { + override fun fixLayout(view: View) { + // 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 { + activity?.onBackPressedDispatcher?.onBackPressed() + } + + setupDatabaseConfigInputs(binding) + setupGranularToggles(binding) + setupAuthActions(binding) + setupPluginActions(binding) + + binding.syncConnectBtn.setOnClickListener { + connect(binding) + } + + binding.syncNowBtn.setOnClickListener { + showToast("Syncing...") + FirestoreSyncManager.syncNow(requireContext()) + view?.postDelayed({ updateUI() }, 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") + } + } + + // 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 setupDatabaseConfigInputs(binding: FragmentSyncSettingsBinding) { + val context = requireContext() + binding.apply { + // Fix: Use getKey to ensure we get the clean string value (handling JSON quotes if any) + syncApiKey.setText(context.getKey(FirestoreSyncManager.FIREBASE_API_KEY) ?: "") + syncProjectId.setText(context.getKey(FirestoreSyncManager.FIREBASE_PROJECT_ID) ?: "") + syncAppId.setText(context.getKey(FirestoreSyncManager.FIREBASE_APP_ID) ?: "") + + val checkBtn = { + syncConnectBtn.isEnabled = syncApiKey.text?.isNotBlank() == true && + syncProjectId.text?.isNotBlank() == true && + syncAppId.text?.isNotBlank() == true + } + + syncApiKey.doAfterTextChanged { checkBtn() } + syncProjectId.doAfterTextChanged { checkBtn() } + syncAppId.doAfterTextChanged { checkBtn() } + checkBtn() + } + } + + private fun setupAuthActions(binding: FragmentSyncSettingsBinding) { + 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.") + 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(binding: FragmentSyncSettingsBinding) { + 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...") + view?.postDelayed({ updateUI() }, 1500) + } + + 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) { + 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)) + } else { + 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 + + // 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 + } + + 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.syncPendingPluginsCard.isVisible = false + } + } +} 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/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 20d33c11218..1a6e9832355 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.utils import android.content.Context import android.content.SharedPreferences +import android.util.Log import androidx.preference.PreferenceManager import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.json.JsonMapper @@ -23,8 +24,6 @@ const val USER_SELECTED_HOMEPAGE_API = "home_api_used" const val USER_PROVIDER_API = "user_custom_sites" const val PREFERENCES_NAME = "rebuild_preference" -// TODO degelgate by value for get & set - class PreferenceDelegate( val key: String, val default: T //, private val klass: KClass ) { @@ -88,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}" } @@ -107,106 +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 { - getSharedPrefs().edit { - putString(path, mapper.writeValueAsString(value)) - } - } catch (e: Exception) { - logError(e) +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) } + // Hook for Sync: Write + FirestoreSyncManager.pushWrite(path, json) + } catch (e: Exception) { + logError(e) } +} - fun Context.getKey(path: String, valueType: Class): T? { - try { - val json: String = getSharedPrefs().getString(path, null) ?: return null - return json.toKotlinObject(valueType) - } catch (e: Exception) { - return null +// Internal local set without sync hook (used by sync manager to avoid loops) +fun Context.setKeyLocal(path: String, value: T) { + 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.setKey(folder: String, path: String, value: T) { - setKey(getFolderName(folder, path), value) - } +fun Context.setKeyLocal(folder: String, path: String, value: T) { + setKeyLocal(DataStore.getFolderName(folder, path), value) +} - inline fun String.toKotlinObject(): T { - return mapper.readValue(this, T::class.java) +fun Context.removeKeyLocal(path: String) { + try { + getSharedPrefs().edit { + remove(path) + } + } catch (e: Exception) { + logError(e) } +} - fun String.toKotlinObject(valueType: Class): T { - return mapper.readValue(this, valueType) +fun Context.getKey(path: String, valueType: Class): T? { + try { + val json: String = getSharedPrefs().getString(path, null) ?: return null + Log.d("DataStore", "getKey(Class) $path raw: '$json'") + return json.toKotlinObject(valueType) + } catch (e: Exception) { + Log.e("DataStore", "getKey(Class) $path error: ${e.message}") + return null } +} + +fun Context.setKey(folder: String, path: String, value: T) { + setKey(DataStore.getFolderName(folder, path), value) +} + +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 - return json.toKotlinObject() +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) { - return null + // Log.w("DataStore", "getKey(Reified) $path parse fail: ${e.message}, trying fallback") + // FALLBACK: If JSON parsing fails, try manual conversion for common types + val fallback: T? = when { + T::class == String::class -> { + // If it's a string, try removing literal double quotes if they exist at start/end + if (json.startsWith("\"") && json.endsWith("\"") && json.length >= 2) { + json.substring(1, json.length - 1) as T + } else { + json as T + } + } + T::class == Boolean::class -> { + (json.lowercase() == "true" || json == "1") as T + } + T::class == Long::class -> { + json.toLongOrNull() as? T ?: defVal + } + T::class == Int::class -> { + json.toIntOrNull() as? T ?: defVal + } + else -> defVal + } + // Log.d("DataStore", "getKey(Reified) $path fallback: '$fallback'") + fallback } + } catch (e: Exception) { + Log.e("DataStore", "getKey(Reified) $path total fail: ${e.message}") + return defVal } +} - inline fun Context.getKey(path: String): T? { - return getKey(path, null) - } +inline fun Context.getKey(path: String): T? { + return getKey(path, null) +} - inline fun Context.getKey(folder: String, path: String): T? { - return getKey(getFolderName(folder, path), null) - } +inline fun Context.getKey(folder: String, path: String): T? { + return getKey(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 217dc2a5205..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,6 +1,7 @@ package com.lagradost.cloudstream3.utils import android.content.Context +import com.lagradost.cloudstream3.utils.setKeyLocal import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.CloudStreamApp.Companion.context @@ -43,6 +44,7 @@ const val RESULT_WATCH_STATE_DATA = "result_watch_state_data" const val RESULT_SUBSCRIBED_STATE_DATA = "result_subscribed_state_data" const val RESULT_FAVORITES_STATE_DATA = "result_favorites_state_data" const val RESULT_RESUME_WATCHING = "result_resume_watching_2" // changed due to id changes +const val RESULT_RESUME_WATCHING_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/DownloadFileWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt index 0b9b81e4024..0e065cd5c1c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt @@ -10,7 +10,7 @@ import androidx.work.WorkerParameters import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.getKey import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_INFO import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_PACKAGE import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadCheck @@ -101,4 +101,4 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt new file mode 100644 index 00000000000..e04fbf779b8 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FirestoreSyncManager.kt @@ -0,0 +1,788 @@ +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 +import com.google.firebase.firestore.SetOptions +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.DataStore +import com.lagradost.cloudstream3.utils.getDefaultSharedPrefs +import com.lagradost.cloudstream3.utils.getSharedPrefs +import com.lagradost.cloudstream3.utils.getKeys +import com.lagradost.cloudstream3.utils.setKey +import com.lagradost.cloudstream3.utils.setKeyLocal +import com.lagradost.cloudstream3.utils.removeKey +import com.lagradost.cloudstream3.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 +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 with generic tombstone support and Auth. + */ +object FirestoreSyncManager : androidx.lifecycle.DefaultLifecycleObserver { + private const val TAG = "FirestoreSync" + private const val SYNC_COLLECTION = "users" + private const val TIMESTAMPS_PREF = "sync_timestamps" + + // Internal keys + const val PENDING_PLUGINS_KEY = "pending_plugins_install" + const val IGNORED_PLUGINS_KEY = "firestore_ignored_plugins_key" + + private var db: FirebaseFirestore? = null + private var auth: FirebaseAuth? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val isInitializing = AtomicBoolean(false) + private var isConnected = false + + private val throttleBatch = ConcurrentHashMap() + private val syncLogs = mutableListOf() + + var lastInitError: String? = null + private set + + var lastSyncDebugInfo: String = "No sync recorded yet." + private set + + fun getLogs(): String { + return syncLogs.joinToString("\n") + } + + private fun log(message: String) { + val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + val entry = "[${sdf.format(Date())}] $message" + syncLogs.add(entry) + if (syncLogs.size > 100) syncLogs.removeAt(0) + Log.d(TAG, entry) + } + + // Config keys in local DataStore + const val FIREBASE_API_KEY = "firebase_api_key" + const val FIREBASE_PROJECT_ID = "firebase_project_id" + const val FIREBASE_APP_ID = "firebase_app_id" + const val FIREBASE_ENABLED = "firebase_sync_enabled" + const val FIREBASE_LAST_SYNC = "firebase_last_sync" + + private const val ACCOUNTS_KEY = "data_store_helper/account" + private const val SETTINGS_SYNC_KEY = "settings" + private const val DATA_STORE_DUMP_KEY = "data_store_dump" + + // Ultra-granular sync control keys + const val SYNC_SETTING_APPEARANCE = "sync_setting_appearance" + const val SYNC_SETTING_PLAYER = "sync_setting_player" + const val SYNC_SETTING_DOWNLOADS = "sync_setting_downloads" + const val SYNC_SETTING_GENERAL = "sync_setting_general" + const val SYNC_SETTING_ACCOUNTS = "sync_setting_accounts" + const val SYNC_SETTING_BOOKMARKS = "sync_setting_bookmarks" + const val SYNC_SETTING_RESUME_WATCHING = "sync_setting_resume_watching" + const val SYNC_SETTING_REPOSITORIES = "sync_setting_repositories" + const val SYNC_SETTING_PLUGINS = "sync_setting_plugins" + const val SYNC_SETTING_HOMEPAGE_API = "sync_setting_homepage_api" + const val SYNC_SETTING_PINNED_PROVIDERS = "sync_setting_pinned_providers" + + // Generic Wrapper for all sync data + data class SyncPayload( + val v: Any?, // Value (JSON string or primitive) + val t: Long, // Timestamp + val d: Boolean = false // IsDeleted (Tombstone) + ) + + data class SyncConfig( + val apiKey: String, + val projectId: String, + val appId: String + ) + + // --- Auth Public API --- + + 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) } + } + + fun register(email: String, pass: String, callback: (Boolean, String?) -> Unit) { + val currentAuth = auth ?: return callback(false, "Firebase not initialized") + currentAuth.createUserWithEmailAndPassword(email, pass) + .addOnSuccessListener { callback(true, null) } + .addOnFailureListener { callback(false, it.message) } + } + + fun loginOrRegister(email: String, pass: String, callback: (Boolean, String?) -> Unit) { + login(email, pass) { success, msg -> + if (success) { + callback(true, null) + } else { + // Check if error implies user not found, or just try registering + // Simple approach: Try registering if login fails + log("Login failed, trying registration... ($msg)") + register(email, pass) { regSuccess, regMsg -> + if (regSuccess) { + callback(true, null) + } else { + // Return the login error if registration also fails, or a combined message + callback(false, "Login: $msg | Register: $regMsg") + } + } + } + } + } + + fun logout(context: Context) { + auth?.signOut() + // Clear local timestamps to force re-sync on next login + context.getSharedPreferences(TIMESTAMPS_PREF, Context.MODE_PRIVATE).edit().clear().apply() + log("Logged out.") + } + + // --- Initialization --- + + override fun onStop(owner: androidx.lifecycle.LifecycleOwner) { + super.onStop(owner) + // Ensure pending writes are flushed immediately + // Do NOT call pushAllLocalData() as it refreshes timestamps for all keys, reviving deleted items (zombies) + scope.launch { + flushBatch() + } + } + + fun isEnabled(context: Context): Boolean { + // Use getKey to handle potential JSON string format from DataStore + return context.getKey(FIREBASE_ENABLED) ?: false + } + + fun initialize(context: Context) { + com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread { + try { + androidx.lifecycle.ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } catch (e: Exception) { } + } + + if (!isEnabled(context)) return + + // Use getKey to clean up any JSON quotes around the string values + val config = SyncConfig( + apiKey = context.getKey(FIREBASE_API_KEY) ?: "", + projectId = context.getKey(FIREBASE_PROJECT_ID) ?: "", + appId = context.getKey(FIREBASE_APP_ID) ?: "" + ) + + if (config.apiKey.isNotBlank() && config.projectId.isNotBlank()) { + initialize(context, config) + } + } + + fun initialize(context: Context, config: SyncConfig) { + if (isInitializing.getAndSet(true)) return + + scope.launch { + try { + val options = FirebaseOptions.Builder() + .setApiKey(config.apiKey) + .setProjectId(config.projectId) + .setApplicationId(config.appId) + .build() + + val appName = "sync_${config.projectId.replace(":", "_")}" + val app = try { + FirebaseApp.getInstance(appName) + } catch (e: Exception) { + FirebaseApp.initializeApp(context, options, appName) + } + + db = FirebaseFirestore.getInstance(app) + auth = FirebaseAuth.getInstance(app) + isConnected = true + + // Save config + context.setKey(FIREBASE_API_KEY, config.apiKey) + context.setKey(FIREBASE_PROJECT_ID, config.projectId) + context.setKey(FIREBASE_APP_ID, config.appId) + context.setKey(FIREBASE_ENABLED, true) + + log("Firebase initialized. Waiting for User...") + + // Auth State Listener + auth?.addAuthStateListener { firebaseAuth -> + val user = firebaseAuth.currentUser + if (user != null) { + log("User signed in: ${user.email}") + setupRealtimeListener(context, user.uid) + } else { + log("User signed out.") + // Detach listeners if any? (Firestore handles this mostly) + } + } + + } catch (e: Exception) { + lastInitError = e.message + log("Init Error: ${e.message}") + } finally { + isInitializing.set(false) + } + } + } + + private fun setupRealtimeListener(context: Context, uid: String) { + db?.collection(SYNC_COLLECTION)?.document(uid)?.addSnapshotListener { snapshot, e -> + if (e != null) { + log("Listen error: ${e.message}") + return@addSnapshotListener + } + if (snapshot != null && snapshot.exists()) { + scope.launch { + applyRemoteData(context, snapshot) + } + } else { + // New user / empty doc -> Push local + log("Empty remote doc, pushing local data.") + pushAllLocalData(context, immediate = true) + } + } + } + + // --- Core Logic --- + + // Local Timestamp Management + private fun setLocalTimestamp(context: Context, key: String, timestamp: Long) { + context.getSharedPreferences(TIMESTAMPS_PREF, Context.MODE_PRIVATE).edit { + putLong(key, timestamp) + } + } + + private fun getLocalTimestamp(context: Context, key: String): Long { + return context.getSharedPreferences(TIMESTAMPS_PREF, Context.MODE_PRIVATE).getLong(key, 0L) + } + + // 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() + } + + // ... + + // --- 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()) + } + } + } + + fun notifyPluginDeleted(internalName: String) { + scope.launch { + val idx = cachedRemotePlugins.indexOfFirst { it.internalName.trim().equals(internalName.trim(), ignoreCase = true) } + if (idx != -1) { + val existing = cachedRemotePlugins[idx] + if (!existing.isDeleted) { + cachedRemotePlugins[idx] = existing.copy(isDeleted = true, addedDate = System.currentTimeMillis()) + log("Marking plugin $internalName as DELETED in sync.") + pushWriteDirect(PLUGINS_KEY, cachedRemotePlugins.toJson()) + } + } 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 var flushJob: Job? = null + private fun triggerFlush() { + if (flushJob?.isActive == true) return + flushJob = scope.launch { + delay(2000) // 2s debounce + flushBatch() + } + } + + 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) + } + } + + 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 applyRemoteData(context: Context, snapshot: DocumentSnapshot) { + val remoteMap = snapshot.data ?: return + val currentUid = auth?.currentUser?.uid ?: return + + log("Applying remote data (${remoteMap.size} keys)") + + remoteMap.forEach { (key, rawPayload) -> + if (key == "last_sync") return@forEach + + try { + // generic parsing + // Firestore stores generic maps as Map + if (rawPayload !is Map<*, *>) return@forEach + + // manual mapping to SyncPayload + val v = rawPayload["v"] + val t = (rawPayload["t"] as? Number)?.toLong() ?: 0L + val d = (rawPayload["d"] as? Boolean) ?: false + + val localT = getLocalTimestamp(context, key) + + if (t > localT) { + // Remote is newer + applyPayload(context, key, v, d) + setLocalTimestamp(context, key, t) + + // Check for Continue Watching updates and trigger UI refresh + if (key.contains("result_resume_watching")) { + com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread { + MainActivity.syncUpdatedEvent.invoke(true) + } + } + } + } catch (e: Exception) { + log("Error parsing key $key: ${e.message}") + } + } + } + + // 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 + } + + 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 } + + if (match != null) { + log("Found in ${repo.name}. Installing...") + val success = PluginManager.downloadPlugin( + activity, + match.second.url, + match.second.internalName, + repo.url, + true + ) + + if (success) { + removeFromPending(context, plugin) + return true + } + } + } + + log("Could not find repository for plugin: ${plugin.internalName}") + CommonActivity.showToast(activity, "Could not find source repository for ${plugin.internalName}", 1) + return false + } + + suspend fun installAllPending(activity: Activity) { + val context = activity.applicationContext + val pending = getPendingPlugins(context) + if (pending.isEmpty()) return + + // Batch optimization: Fetch all repo plugins ONCE + val savedRepos = context.getKey>(REPOSITORIES_KEY) ?: emptyArray() + val allRepos = (savedRepos + RepositoryManager.PREBUILT_REPOSITORIES).distinctBy { it.url } + + val onlineMap = mutableMapOf>() // InternalName -> (PluginUrl, RepoUrl) + + allRepos.forEach { repo -> + RepositoryManager.getRepoPlugins(repo.url)?.forEach { (repoUrl, sitePlugin) -> + onlineMap[sitePlugin.internalName] = Pair(sitePlugin.url, repoUrl) + } + } + + var installedCount = 0 + val remaining = mutableListOf() + + pending.forEach { p -> + val match = onlineMap[p.internalName] + if (match != null) { + val (url, repoUrl) = match + val success = PluginManager.downloadPlugin(activity, url, p.internalName, repoUrl, true) + if (success) installedCount++ else remaining.add(p) + } else { + remaining.add(p) + } + } + + // Update pending list with failures/missing + context.setKeyLocal(PENDING_PLUGINS_KEY, remaining.toJson()) + + if (installedCount > 0) { + CommonActivity.showToast(activity, "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) + + // Add to ignored list + val ignoredJson = context.getSharedPrefs().getString(IGNORED_PLUGINS_KEY, "[]") ?: "[]" + val ignoredList = try { + parseJson>(ignoredJson).toMutableSet() + } catch(e:Exception) { mutableSetOf() } + + ignoredList.add(plugin.internalName) + context.setKeyLocal(IGNORED_PLUGINS_KEY, ignoredList.toJson()) + } + + fun ignoreAllPendingPlugins(context: Context) { + val pending = getPendingPlugins(context) + if (pending.isNotEmpty()) { + val ignoredJson = context.getSharedPrefs().getString(IGNORED_PLUGINS_KEY, "[]") ?: "[]" + val ignoredList = try { + parseJson>(ignoredJson).toMutableSet() + } catch(e:Exception) { mutableSetOf() } + + pending.forEach { ignoredList.add(it.internalName) } + + context.setKeyLocal(IGNORED_PLUGINS_KEY, ignoredList.toJson()) + context.setKeyLocal(PENDING_PLUGINS_KEY, "[]") + } + } + + private fun handleRemotePlugins(context: Context, remoteJson: String) { + try { + val remoteList = parseJson>(remoteJson).toList() + 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 (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() + 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"? + } + } + } + + // Also remove from Pending if present + if (rawPending.removeIf { isMatchingPlugin(remote, it) }) { + changed = true + } + + } 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 + } + } + } + } + + // --- 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}") + } + } + + // --- Helpers --- + + 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 + } + + 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) + } + } + if (immediate) flushBatch() + } + } + + 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/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_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/layout/fragment_sync_settings.xml b/app/src/main/res/layout/fragment_sync_settings.xml new file mode 100644 index 00000000000..6033beaf0fd --- /dev/null +++ b/app/src/main/res/layout/fragment_sync_settings.xml @@ -0,0 +1,600 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index 784fc515e8f..62d7efa2d95 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -413,6 +413,13 @@ app:exitAnim="@anim/exit_anim" app:popEnterAnim="@anim/enter_anim" app:popExitAnim="@anim/exit_anim" /> + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 48b69232abe..83eeb54a84d 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -11,6 +11,7 @@ #111111 #1C1C20 #161616 + #EF5350 #e9eaee #9ba0a4 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" /> + +