From 4fef64eba82ea6e3d8b2e50c535209e4a09901f0 Mon Sep 17 00:00:00 2001 From: ramailo1 Date: Thu, 2 Apr 2026 15:58:04 +0100 Subject: [PATCH 1/4] Implement Google Drive sync provider, settings UI, and backup utilities improvements --- app/build.gradle.kts | 12 + .../lagradost/cloudstream3/MainActivity.kt | 18 + .../syncproviders/google/SyncManager.kt | 339 ++++++++++++++++++ .../ui/settings/SettingsUpdates.kt | 5 + .../ui/settings/SyncSettingsFragment.kt | 158 ++++++++ .../cloudstream3/utils/BackupUtils.kt | 18 +- app/src/main/res/drawable/ic_google_logo.xml | 18 + app/src/main/res/layout/main_settings.xml | 1 + .../main/res/navigation/mobile_navigation.xml | 16 + app/src/main/res/values/strings.xml | 31 ++ app/src/main/res/xml/settings_sync.xml | 56 +++ app/src/main/res/xml/settings_updates.xml | 30 +- gradle/libs.versions.toml | 8 + 13 files changed, 697 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt create mode 100644 app/src/main/res/drawable/ic_google_logo.xml create mode 100644 app/src/main/res/xml/settings_sync.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 722fcf58ed5..4bb66daea76 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -87,6 +87,11 @@ android { "SIMKL_CLIENT_SECRET", "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\"" ) + buildConfigField( + "String", + "GOOGLE_CLIENT_ID", + "\"" + (localProperties["google.client_id"] ?: "") + "\"" + ) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -227,6 +232,13 @@ dependencies { implementation(libs.nicehttp) // HTTP Lib implementation(project(":library")) + + // Google Drive Sync + implementation(libs.play.services.auth) // AuthorizationClient for Drive token + implementation(libs.credentials) + implementation(libs.credentials.play.services.auth) + implementation(libs.googleid) + implementation(libs.kotlinx.coroutines.play.services) // .await() on GMS Tasks } tasks.register("androidSourcesJar") { diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index a7c0a8a2795..e199b8dc6db 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -100,6 +100,7 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STR import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.localListApi import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.syncproviders.google.SyncManager import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.WatchType @@ -515,6 +516,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa R.id.navigation_settings_extensions, R.id.navigation_settings_plugins, R.id.navigation_test_providers, + R.id.navigation_settings_sync, ).contains(destination.id) @@ -628,6 +630,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } + override fun onStart() { + super.onStart() + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + if (prefs.getBoolean("sync_auto_on_launch", false) && SyncManager.isEnabled(this)) { + SyncManager.pull(this) + } + } + override fun onResume() { super.onResume() afterPluginsLoadedEvent += ::onAllPluginsLoaded @@ -658,6 +668,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } + override fun onStop() { + super.onStop() + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + if (prefs.getBoolean("sync_auto_on_close", false) && SyncManager.isEnabled(this)) { + SyncManager.push(this) + } + } + override fun dispatchKeyEvent(event: KeyEvent): Boolean = CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt new file mode 100644 index 00000000000..97ed629125e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt @@ -0,0 +1,339 @@ +package com.lagradost.cloudstream3.syncproviders.google + +import android.app.PendingIntent +import android.content.Context +import com.google.android.gms.auth.api.identity.AuthorizationRequest +import com.google.android.gms.auth.api.identity.Identity +import com.google.android.gms.common.api.Scope +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.BackupUtils +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.DataStore.mapper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +/** + * Orchestrates cross-device sync via Google Drive's App Data folder. + * + * The App Data folder is: + * - Hidden from the user in Drive UI. + * - Scoped to this app only (privacy-safe). + * - Free, within normal Drive quota. + * + * Auth strategy (modern, no deprecated APIs): + * - Sign-in identity : Credential Manager + GetGoogleIdOption (handled in SyncSettingsFragment) + * - Drive token : Identity.getAuthorizationClient().authorize() + */ +object SyncManager { + + private const val SYNC_PREFS = "cs3_sync_prefs" + private const val KEY_LAST_SYNC_TIME = "last_sync_timestamp" + private const val KEY_IS_ENABLED = "sync_enabled" + private const val KEY_EMAIL = "connected_email" + private const val DRIVE_SCOPE_URL = "https://www.googleapis.com/auth/drive.appdata" + private const val BACKUP_FILENAME = "cs3_backup.json" + + /** Result emitted after each push or pull attempt. */ + sealed class SyncResult { + data class Push(val isSuccess: Boolean) : SyncResult() + data class Pull(val isSuccess: Boolean) : SyncResult() + data class NeedsAuth(val pendingIntent: PendingIntent) : SyncResult() + } + + private val _syncEvents = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + val syncEvents = _syncEvents.asSharedFlow() + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + // ─── Auth ────────────────────────────────────────────────────────────────── + + fun isEnabled(context: Context): Boolean = + context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_IS_ENABLED, false) + + /** + * Returns a [GetGoogleIdOption] the Fragment passes to CredentialManager. + * Requesting the Drive scope here ensures the user is prompted for it during sign-in + * (when a serverClientId is configured). + */ + fun buildGoogleIdOption(): GetGoogleIdOption = + GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(false) // show all accounts, not just previously-used + .setAutoSelectEnabled(false) + .apply { + if (BuildConfig.GOOGLE_CLIENT_ID.isNotBlank()) { + setServerClientId(BuildConfig.GOOGLE_CLIENT_ID) + } + } + .build() + + /** Called by the Fragment after a successful CredentialManager result. */ + fun onSignInSuccess(context: Context, email: String) { + context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_IS_ENABLED, true) + .putString(KEY_EMAIL, email) + .apply() + } + + fun signOut(context: Context) { + context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_IS_ENABLED, false) + .remove(KEY_EMAIL) + .remove(KEY_LAST_SYNC_TIME) + .apply() + } + + fun getConnectedEmail(context: Context): String? = + context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) + .getString(KEY_EMAIL, null) + + /** + * Obtains a fresh OAuth2 access token for the Drive.appdata scope using the + * modern [Identity.getAuthorizationClient] API (replaces deprecated GoogleAuthUtil). + */ + private suspend fun getToken(context: Context): String? = + withContext(Dispatchers.IO) { + val email = getConnectedEmail(context) + println("[SyncManager] getToken() called for $email") + try { + val authRequest = AuthorizationRequest.builder() + .setRequestedScopes(listOf(Scope(DRIVE_SCOPE_URL))) + .build() + val result = Identity.getAuthorizationClient(context) + .authorize(authRequest) + .await() + + println("[SyncManager] getToken() result: hasToken=${result.accessToken != null}, hasPending=${result.pendingIntent != null}") + + if (result.accessToken == null && result.pendingIntent != null) { + println("[SyncManager] getToken() → Emitting NeedsAuth") + // We can't return PendingIntent through String?, but we can signal it + // The push/pull calls will handle the emission + } + + result.accessToken + } catch (e: Exception) { + println("[SyncManager] getToken() failed: ${e.message}") + logError(e) + null + } + } + + /** Internal helper that returns the full result to handle resolutions */ + private suspend fun getAuthResult(context: Context): com.google.android.gms.auth.api.identity.AuthorizationResult? = + withContext(Dispatchers.IO) { + val email = getConnectedEmail(context) + try { + val authRequest = AuthorizationRequest.builder() + .setRequestedScopes(listOf(Scope(DRIVE_SCOPE_URL))) + .build() + Identity.getAuthorizationClient(context) + .authorize(authRequest) + .await() + } catch (e: Exception) { + println("[SyncManager] getAuthResult() failed: $e") + null + } + } + + // ─── Push / Pull ──────────────────────────────────────────────────────────── + + /** + * Serialises the local DataStore + Settings into a JSON snapshot and + * uploads it to Drive App Data folder. Last-write wins on conflict. + */ + fun push(context: Context) = ioSafe { + println("[SyncManager] push() start") + if (!isEnabled(context)) { + println("[SyncManager] push() aborted: sync not enabled") + return@ioSafe + } + + val result = getAuthResult(context) + val token = result?.accessToken + + if (token == null) { + if (result?.pendingIntent != null) { + println("[SyncManager] push() needs auth resolution") + _syncEvents.emit(SyncResult.NeedsAuth(result.pendingIntent!!)) + } else { + println("[SyncManager] push() failed: token null") + _syncEvents.emit(SyncResult.Push(isSuccess = false)) + } + return@ioSafe + } + + val backup = BackupUtils.getBackup(context) + if (backup == null) { + println("[SyncManager] push() failed: backup null") + _syncEvents.emit(SyncResult.Push(isSuccess = false)) + return@ioSafe + } + val json = mapper.writeValueAsString(backup) + + try { + println("[SyncManager] push() uploading...") + GoogleDriveApi.upload(httpClient, token, BACKUP_FILENAME, json) + context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) + .edit().putLong(KEY_LAST_SYNC_TIME, System.currentTimeMillis()).apply() + println("[SyncManager] push() success") + _syncEvents.emit(SyncResult.Push(isSuccess = true)) + } catch (e: Exception) { + println("[SyncManager] push() exception: ${e.message}") + logError(e) + _syncEvents.emit(SyncResult.Push(isSuccess = false)) + } + } + + /** + * Downloads the latest snapshot from Drive and merges it into the local state. + * The merge strategy is last-write-wins at the per-data-type level (timestamp + * comparison). A full set-union is performed for bookmarks/subscriptions so items + * are never silently deleted by a pull. + */ + fun pull(context: Context) = ioSafe { + println("[SyncManager] pull() start") + if (!isEnabled(context)) { + println("[SyncManager] pull() aborted: sync not enabled") + return@ioSafe + } + + val result = getAuthResult(context) + val token = result?.accessToken + + if (token == null) { + if (result?.pendingIntent != null) { + println("[SyncManager] pull() needs auth resolution") + _syncEvents.emit(SyncResult.NeedsAuth(result.pendingIntent!!)) + } else { + println("[SyncManager] pull() failed: token null") + _syncEvents.emit(SyncResult.Pull(isSuccess = false)) + } + return@ioSafe + } + + try { + println("[SyncManager] pull() downloading...") + val json = GoogleDriveApi.download(httpClient, token, BACKUP_FILENAME) + if (json == null) { + println("[SyncManager] pull() failed: json null") + _syncEvents.emit(SyncResult.Pull(isSuccess = false)) + return@ioSafe + } + val remoteBackup = mapper.readValue(json, BackupUtils.BackupFile::class.java) + + // TODO Phase 3: add real timestamp-based merge instead of naive restore + println("[SyncManager] pull() restoring...") + BackupUtils.restore( + context, + remoteBackup, + restoreSettings = true, + restoreDataStore = true + ) + context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) + .edit().putLong(KEY_LAST_SYNC_TIME, System.currentTimeMillis()).apply() + println("[SyncManager] pull() success") + _syncEvents.emit(SyncResult.Pull(isSuccess = true)) + } catch (e: Exception) { + println("[SyncManager] pull() exception: ${e.message}") + logError(e) + _syncEvents.emit(SyncResult.Pull(isSuccess = false)) + } + } + + fun getLastSyncTime(context: Context): Long = + context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) + .getLong(KEY_LAST_SYNC_TIME, 0L) +} + +/** + * Minimal Google Drive App Data REST v3 implementation using OkHttp. + * + * Uses multipart upload for create, PATCH for update. + * Keeps only the single newest backup file to save quota. + */ +internal object GoogleDriveApi { + private const val BASE = "https://www.googleapis.com/drive/v3" + private const val UPLOAD_BASE = "https://www.googleapis.com/upload/drive/v3" + private const val APP_DATA_SPACE = "appDataFolder" + + /** Finds the Drive file ID for [filename] in the App Data folder, or null. */ + private fun findFileId(client: OkHttpClient, token: String, filename: String): String? { + val url = "$BASE/files?spaces=$APP_DATA_SPACE" + + "&q=name+%3D+%27$filename%27" + + "&fields=files(id)" + val req = Request.Builder().url(url) + .addHeader("Authorization", "Bearer $token").get().build() + val body = client.newCall(req).execute().use { it.body.string() } + val files = JSONObject(body).optJSONArray("files") ?: return null + return if (files.length() > 0) files.getJSONObject(0).getString("id") else null + } + + /** + * Upload or overwrite [filename] with [content]. + * Uses a simple multipart request which is well within Drive's 5 MB limit for metadata+JSON. + */ + fun upload(client: OkHttpClient, token: String, filename: String, content: String) { + val existingId = findFileId(client, token, filename) + + val boundary = "cs3_sync_boundary" + val meta = """{"name":"$filename","parents":["$APP_DATA_SPACE"]}""" + val body = "--$boundary\r\n" + + "Content-Type: application/json; charset=UTF-8\r\n\r\n" + + "$meta\r\n" + + "--$boundary\r\n" + + "Content-Type: application/json\r\n\r\n" + + "$content\r\n" + + "--$boundary--" + + val reqBody = body.toRequestBody("multipart/related; boundary=$boundary".toMediaTypeOrNull()) + + val req = if (existingId == null) { + // Create + Request.Builder() + .url("$UPLOAD_BASE/files?uploadType=multipart&spaces=$APP_DATA_SPACE") + .addHeader("Authorization", "Bearer $token") + .post(reqBody).build() + } else { + // Update (PATCH) + Request.Builder() + .url("$UPLOAD_BASE/files/$existingId?uploadType=multipart") + .addHeader("Authorization", "Bearer $token") + .patch(reqBody).build() + } + + client.newCall(req).execute().use { response -> + if (!response.isSuccessful) { + throw Exception("Drive upload failed: ${response.code} ${response.body.string()}") + } + } + } + + /** Downloads [filename] content from App Data folder or returns null. */ + fun download(client: OkHttpClient, token: String, filename: String): String? { + val fileId = findFileId(client, token, filename) ?: return null + val req = Request.Builder() + .url("$BASE/files/$fileId?alt=media") + .addHeader("Authorization", "Bearer $token") + .get().build() + return client.newCall(req).execute().use { response -> + if (!response.isSuccessful) null else response.body.string() + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index 2b74eab4cda..ad5e34157a9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -73,6 +73,11 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { setPreferencesFromResource(R.xml.settings_updates, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + getPref(R.string.sync_category_account)?.setOnPreferenceClickListener { + findNavController().navigate(R.id.action_navigation_global_to_navigation_settings_sync) + return@setOnPreferenceClickListener true + } + getPref(R.string.backup_key)?.setOnPreferenceClickListener { BackupUtils.backup(activity) return@setOnPreferenceClickListener 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..908ea6b9abb --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt @@ -0,0 +1,158 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.exceptions.GetCredentialException +import androidx.lifecycle.lifecycleScope +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreferenceCompat +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.syncproviders.google.SyncManager +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.collectLatest +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class SyncSettingsFragment : PreferenceFragmentCompat() { + + private val credentialManager by lazy { CredentialManager.create(requireContext()) } + + private val syncResolutionLauncher = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> + println("[SyncSettings] Resolution finished: ${result.resultCode}") + if (result.resultCode == android.app.Activity.RESULT_OK) { + // Retry sync logic would go here, or just inform user to try again + println("[SyncSettings] Resolution success, user should retry sync") + Toast.makeText(context, R.string.sync_auth_success, Toast.LENGTH_SHORT).show() + } + } + + // ─── Fragment lifecycle ───────────────────────────────────────────────────── + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.settings_sync, rootKey) + + findPreference("sync_google_drive_connect")?.setOnPreferenceClickListener { + println("[SyncSettings] Connect/Disconnect clicked") + val email = SyncManager.getConnectedEmail(requireContext()) + if (email == null) { + // Not signed in → launch Credential Manager bottom sheet + launchSignIn() + } else { + // Already signed in → sign out + SyncManager.signOut(requireContext()) + updateUiState() + Toast.makeText(context, R.string.sync_disconnected_toast, Toast.LENGTH_SHORT).show() + } + true + } + + findPreference("sync_push_now")?.setOnPreferenceClickListener { + println("[SyncSettings] Push Now clicked") + SyncManager.push(requireContext()) + Toast.makeText(context, R.string.sync_push_started, Toast.LENGTH_SHORT).show() + true + } + + findPreference("sync_pull_now")?.setOnPreferenceClickListener { + println("[SyncSettings] Pull Now clicked") + SyncManager.pull(requireContext()) + Toast.makeText(context, R.string.sync_pull_started, Toast.LENGTH_SHORT).show() + true + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + updateUiState() + + lifecycleScope.launch { + SyncManager.syncEvents.collectLatest { result -> + println("[SyncSettings] syncEvent received: $result") + + if (result is SyncManager.SyncResult.NeedsAuth) { + println("[SyncSettings] Launching auth resolution") + try { + syncResolutionLauncher.launch( + androidx.activity.result.IntentSenderRequest.Builder(result.pendingIntent.intentSender).build() + ) + } catch (e: Exception) { + println("[SyncSettings] Resolution launch failed: $e") + } + return@collectLatest + } + + updateUiState() + val msgRes = when { + result is SyncManager.SyncResult.Push && result.isSuccess -> R.string.sync_push_success + result is SyncManager.SyncResult.Push && !result.isSuccess -> R.string.sync_push_failed + result is SyncManager.SyncResult.Pull && result.isSuccess -> R.string.sync_pull_success + else -> R.string.sync_pull_failed + } + println("[SyncSettings] Target Toast: ${resources.getResourceEntryName(msgRes)}") + Toast.makeText(context, msgRes, Toast.LENGTH_SHORT).show() + } + } + } + + // ─── Sign-in ──────────────────────────────────────────────────────────────── + + private fun launchSignIn() { + val request = GetCredentialRequest.Builder() + .addCredentialOption(SyncManager.buildGoogleIdOption()) + .build() + + lifecycleScope.launch { + try { + val result = credentialManager.getCredential(requireContext(), request) + val googleIdCred = GoogleIdTokenCredential.createFrom(result.credential.data) + SyncManager.onSignInSuccess(requireContext(), googleIdCred.id) + updateUiState() + Toast.makeText(context, R.string.sync_connected_toast, Toast.LENGTH_SHORT).show() + } catch (e: GetCredentialException) { + Toast.makeText(context, e.message ?: e.type, Toast.LENGTH_LONG).show() + } + } + } + + // ─── UI helpers ───────────────────────────────────────────────────────────── + + private fun updateUiState() { + val ctx = context ?: return + val email = SyncManager.getConnectedEmail(ctx) + val isConnected = email != null + val lastSync = SyncManager.getLastSyncTime(ctx) + + findPreference("sync_google_drive_connect")?.apply { + title = if (isConnected) + getString(R.string.sync_disconnect_title, email) + else + getString(R.string.sync_connect_title) + summary = if (isConnected) + getString(R.string.sync_connected_summary, email) + else + getString(R.string.sync_connect_summary) + } + + findPreference("sync_status")?.summary = if (!isConnected) { + getString(R.string.sync_status_not_connected) + } else if (lastSync == 0L) { + getString(R.string.sync_status_never) + } else { + val formatted = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + .format(Date(lastSync)) + getString(R.string.sync_status_last, formatted) + } + + findPreference("sync_push_now")?.isEnabled = isConnected + findPreference("sync_pull_now")?.isEnabled = isConnected + findPreference("sync_auto_on_launch")?.isEnabled = isConnected + findPreference("sync_auto_on_close")?.isEnabled = isConnected + } +} 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 29410ab4d32..c4260843051 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -107,8 +107,14 @@ object BackupUtils { ) /** false if key should not be contained in backup */ - private fun String.isTransferable(): Boolean { - return !nonTransferableKeys.any { this.contains(it) } + private fun String.isTransferable(context: Context): Boolean { + val pluginSyncEnabled = context.getDefaultSharedPrefs().getBoolean("sync_plugins_enabled", false) + val excluded = if (pluginSyncEnabled) { + nonTransferableKeys.filter { it != PLUGINS_KEY } + } else { + nonTransferableKeys + } + return !excluded.any { this.contains(it) } } private var restoreFileSelector: ActivityResultLauncher>? = null @@ -129,11 +135,11 @@ object BackupUtils { ) @Suppress("UNCHECKED_CAST") - private fun getBackup(context: Context?): BackupFile? { + internal fun getBackup(context: Context?): BackupFile? { if (context == null) return null - val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() } - val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() } + val allData = context.getSharedPrefs().all.filter { it.key.isTransferable(context) } + val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable(context) } val allDataSorted = BackupVars( allData.filter { it.value is Boolean } as? Map, @@ -307,7 +313,7 @@ object BackupUtils { ) { val editor = DataStore.editor(this, isEditingAppSettings) map?.forEach { - if (it.key.isTransferable()) { + if (it.key.isTransferable(this)) { editor.setKeyRaw(it.key, it.value) } } diff --git a/app/src/main/res/drawable/ic_google_logo.xml b/app/src/main/res/drawable/ic_google_logo.xml new file mode 100644 index 00000000000..3da5a098e54 --- /dev/null +++ b/app/src/main/res/drawable/ic_google_logo.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/layout/main_settings.xml b/app/src/main/res/layout/main_settings.xml index ba377455440..5c2da88d3eb 100644 --- a/app/src/main/res/layout/main_settings.xml +++ b/app/src/main/res/layout/main_settings.xml @@ -107,6 +107,7 @@ android:nextFocusUp="@id/settings_credits" android:text="@string/extensions" /> + + + + %d download queued %d downloads queued + + Google Drive Sync + Sync Actions + Sync Options + Connect Google Drive + Sync your watchlist and history across devices + Disconnect (%s) + Connected as %s + Sync Status + Not connected + Never synced + Last synced: %s + Upload to Cloud + Save local data to Google Drive now + Download from Cloud + Restore data from Google Drive now + Sync on app start + Automatically pull from cloud when opening the app + Sync on app close + Automatically push to cloud when closing the app + Sync online plugins + Include the list of installed online plugins in the sync + Google Drive connected + Google Drive disconnected + Uploading to cloud… + Downloading from cloud… + ✓ Upload to cloud successful + ✗ Upload to cloud failed + ✓ Download from cloud successful + ✗ Download from cloud failed + Google Drive authorized! You can now sync. diff --git a/app/src/main/res/xml/settings_sync.xml b/app/src/main/res/xml/settings_sync.xml new file mode 100644 index 00000000000..5db04a8d4d8 --- /dev/null +++ b/app/src/main/res/xml/settings_sync.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/settings_updates.xml b/app/src/main/res/xml/settings_updates.xml index 77ebae47435..45b6c692d73 100644 --- a/app/src/main/res/xml/settings_updates.xml +++ b/app/src/main/res/xml/settings_updates.xml @@ -7,21 +7,25 @@ + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e5a2ade948..9deaad67c90 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,6 +46,9 @@ tvprovider = "1.1.0" video = "1.0.0" workRuntimeKtx = "2.11.1" zipline = "1.24.0" +playServicesAuth = "21.3.0" +credentialsManager = "1.3.0" +googleid = "1.1.1" jvmTarget = "1.8" jdkToolchain = "17" @@ -77,6 +80,7 @@ junit = { module = "junit:junit", version.ref = "junit" } junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "junitKtx" } juniversalchardet = { module = "com.github.albfernandez:juniversalchardet", version.ref = "juniversalchardet" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesCore" } lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleKtx" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" } material = { module = "com.google.android.material:material", version.ref = "material" } @@ -110,6 +114,10 @@ tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovi video = { module = "com.google.android.mediahome:video", version.ref = "video" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } zipline = { module = "app.cash.zipline:zipline-android", version.ref = "zipline" } +play-services-auth = { module = "com.google.android.gms:play-services-auth", version.ref = "playServicesAuth" } +credentials = { module = "androidx.credentials:credentials", version.ref = "credentialsManager" } +credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentialsManager" } +googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "googleid" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } From 2a1e86eeb5e4c28c14391f746be9079cf4b9d182 Mon Sep 17 00:00:00 2001 From: ramailo1 Date: Thu, 2 Apr 2026 17:33:41 +0100 Subject: [PATCH 2/4] Refactor Google Drive Sync: Differential sharding and UI improvements --- .../syncproviders/google/SyncManager.kt | 255 ++++++++---------- .../syncproviders/google/SyncUtils.kt | 55 ++++ .../ui/settings/SyncSettingsFragment.kt | 56 ++-- 3 files changed, 184 insertions(+), 182 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncUtils.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt index 97ed629125e..d54c640e1cd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt @@ -2,15 +2,23 @@ package com.lagradost.cloudstream3.syncproviders.google import android.app.PendingIntent import android.content.Context +import androidx.core.content.edit import com.google.android.gms.auth.api.identity.AuthorizationRequest import com.google.android.gms.auth.api.identity.Identity import com.google.android.gms.common.api.Scope import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.DataStore +import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.mapper +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.RESULT_FAVORITES_STATE_DATA +import com.lagradost.cloudstream3.utils.RESULT_SUBSCRIBED_STATE_DATA +import com.lagradost.cloudstream3.utils.RESULT_WATCH_STATE +import com.lagradost.cloudstream3.utils.RESULT_WATCH_STATE_DATA +import com.lagradost.cloudstream3.utils.VIDEO_POS_DUR import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -23,18 +31,6 @@ import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONObject import java.util.concurrent.TimeUnit -/** - * Orchestrates cross-device sync via Google Drive's App Data folder. - * - * The App Data folder is: - * - Hidden from the user in Drive UI. - * - Scoped to this app only (privacy-safe). - * - Free, within normal Drive quota. - * - * Auth strategy (modern, no deprecated APIs): - * - Sign-in identity : Credential Manager + GetGoogleIdOption (handled in SyncSettingsFragment) - * - Drive token : Identity.getAuthorizationClient().authorize() - */ object SyncManager { private const val SYNC_PREFS = "cs3_sync_prefs" @@ -42,16 +38,18 @@ object SyncManager { private const val KEY_IS_ENABLED = "sync_enabled" private const val KEY_EMAIL = "connected_email" private const val DRIVE_SCOPE_URL = "https://www.googleapis.com/auth/drive.appdata" - private const val BACKUP_FILENAME = "cs3_backup.json" + + private const val META_FILE = "sync_meta.json" + private const val SHARD_TRACKING = "shard_tracking.json" + private const val SHARD_PROGRESS = "shard_progress.json" - /** Result emitted after each push or pull attempt. */ sealed class SyncResult { - data class Push(val isSuccess: Boolean) : SyncResult() - data class Pull(val isSuccess: Boolean) : SyncResult() + data class Push(val isSuccess: Boolean, val error: String? = null) : SyncResult() + data class Pull(val isSuccess: Boolean, val error: String? = null) : SyncResult() data class NeedsAuth(val pendingIntent: PendingIntent) : SyncResult() } - private val _syncEvents = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + private val _syncEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) val syncEvents = _syncEvents.asSharedFlow() private val httpClient = OkHttpClient.Builder() @@ -59,20 +57,13 @@ object SyncManager { .readTimeout(30, TimeUnit.SECONDS) .build() - // ─── Auth ────────────────────────────────────────────────────────────────── - fun isEnabled(context: Context): Boolean = context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) .getBoolean(KEY_IS_ENABLED, false) - /** - * Returns a [GetGoogleIdOption] the Fragment passes to CredentialManager. - * Requesting the Drive scope here ensures the user is prompted for it during sign-in - * (when a serverClientId is configured). - */ fun buildGoogleIdOption(): GetGoogleIdOption = GetGoogleIdOption.Builder() - .setFilterByAuthorizedAccounts(false) // show all accounts, not just previously-used + .setFilterByAuthorizedAccounts(false) .setAutoSelectEnabled(false) .apply { if (BuildConfig.GOOGLE_CLIENT_ID.isNotBlank()) { @@ -81,7 +72,6 @@ object SyncManager { } .build() - /** Called by the Fragment after a successful CredentialManager result. */ fun onSignInSuccess(context: Context, email: String) { context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) .edit() @@ -103,42 +93,8 @@ object SyncManager { context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) .getString(KEY_EMAIL, null) - /** - * Obtains a fresh OAuth2 access token for the Drive.appdata scope using the - * modern [Identity.getAuthorizationClient] API (replaces deprecated GoogleAuthUtil). - */ - private suspend fun getToken(context: Context): String? = - withContext(Dispatchers.IO) { - val email = getConnectedEmail(context) - println("[SyncManager] getToken() called for $email") - try { - val authRequest = AuthorizationRequest.builder() - .setRequestedScopes(listOf(Scope(DRIVE_SCOPE_URL))) - .build() - val result = Identity.getAuthorizationClient(context) - .authorize(authRequest) - .await() - - println("[SyncManager] getToken() result: hasToken=${result.accessToken != null}, hasPending=${result.pendingIntent != null}") - - if (result.accessToken == null && result.pendingIntent != null) { - println("[SyncManager] getToken() → Emitting NeedsAuth") - // We can't return PendingIntent through String?, but we can signal it - // The push/pull calls will handle the emission - } - - result.accessToken - } catch (e: Exception) { - println("[SyncManager] getToken() failed: ${e.message}") - logError(e) - null - } - } - - /** Internal helper that returns the full result to handle resolutions */ private suspend fun getAuthResult(context: Context): com.google.android.gms.auth.api.identity.AuthorizationResult? = withContext(Dispatchers.IO) { - val email = getConnectedEmail(context) try { val authRequest = AuthorizationRequest.builder() .setRequestedScopes(listOf(Scope(DRIVE_SCOPE_URL))) @@ -147,113 +103,127 @@ object SyncManager { .authorize(authRequest) .await() } catch (e: Exception) { - println("[SyncManager] getAuthResult() failed: $e") null } } - // ─── Push / Pull ──────────────────────────────────────────────────────────── - - /** - * Serialises the local DataStore + Settings into a JSON snapshot and - * uploads it to Drive App Data folder. Last-write wins on conflict. - */ fun push(context: Context) = ioSafe { - println("[SyncManager] push() start") - if (!isEnabled(context)) { - println("[SyncManager] push() aborted: sync not enabled") - return@ioSafe - } + if (!isEnabled(context)) return@ioSafe val result = getAuthResult(context) val token = result?.accessToken if (token == null) { - if (result?.pendingIntent != null) { - println("[SyncManager] push() needs auth resolution") - _syncEvents.emit(SyncResult.NeedsAuth(result.pendingIntent!!)) - } else { - println("[SyncManager] push() failed: token null") - _syncEvents.emit(SyncResult.Push(isSuccess = false)) - } + result?.pendingIntent?.let { + _syncEvents.emit(SyncResult.NeedsAuth(it)) + } ?: _syncEvents.emit(SyncResult.Push(isSuccess = false, error = "No access token")) return@ioSafe } - val backup = BackupUtils.getBackup(context) - if (backup == null) { - println("[SyncManager] push() failed: backup null") - _syncEvents.emit(SyncResult.Push(isSuccess = false)) - return@ioSafe - } - val json = mapper.writeValueAsString(backup) - try { - println("[SyncManager] push() uploading...") - GoogleDriveApi.upload(httpClient, token, BACKUP_FILENAME, json) + val trackingKeys = listOf( + RESULT_WATCH_STATE_DATA, + RESULT_FAVORITES_STATE_DATA, + RESULT_SUBSCRIBED_STATE_DATA, + RESULT_WATCH_STATE + ) + + val prefs = context.getSharedPrefs() + val trackingData = mutableMapOf() + DataStore.run { + trackingKeys.forEach { folder -> + context.getKeys("${DataStoreHelper.currentAccount}/$folder").forEach { key -> + prefs.getString(key, null)?.let { + trackingData[key] = it + } + } + } + } + + val progressData = mutableMapOf() + DataStore.run { + context.getKeys("${DataStoreHelper.currentAccount}/$VIDEO_POS_DUR").forEach { key -> + prefs.getString(key, null)?.let { + progressData[key] = it + } + } + } + + if (trackingData.isNotEmpty()) { + val shard = SyncShard(data = trackingData, metadata = trackingData.mapValues { System.currentTimeMillis() }) + GoogleDriveApi.upload(httpClient, token, SHARD_TRACKING, mapper.writeValueAsString(shard)) + } + + if (progressData.isNotEmpty()) { + val shard = SyncShard(data = progressData, metadata = progressData.mapValues { System.currentTimeMillis() }) + GoogleDriveApi.upload(httpClient, token, SHARD_PROGRESS, mapper.writeValueAsString(shard)) + } + + val meta = SyncMetadata(updatedAt = System.currentTimeMillis()) + GoogleDriveApi.upload(httpClient, token, META_FILE, mapper.writeValueAsString(meta)) + context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) .edit().putLong(KEY_LAST_SYNC_TIME, System.currentTimeMillis()).apply() - println("[SyncManager] push() success") + _syncEvents.emit(SyncResult.Push(isSuccess = true)) } catch (e: Exception) { - println("[SyncManager] push() exception: ${e.message}") logError(e) - _syncEvents.emit(SyncResult.Push(isSuccess = false)) + _syncEvents.emit(SyncResult.Push(isSuccess = false, error = e.message)) } } - /** - * Downloads the latest snapshot from Drive and merges it into the local state. - * The merge strategy is last-write-wins at the per-data-type level (timestamp - * comparison). A full set-union is performed for bookmarks/subscriptions so items - * are never silently deleted by a pull. - */ fun pull(context: Context) = ioSafe { - println("[SyncManager] pull() start") - if (!isEnabled(context)) { - println("[SyncManager] pull() aborted: sync not enabled") - return@ioSafe - } + if (!isEnabled(context)) return@ioSafe val result = getAuthResult(context) val token = result?.accessToken if (token == null) { - if (result?.pendingIntent != null) { - println("[SyncManager] pull() needs auth resolution") - _syncEvents.emit(SyncResult.NeedsAuth(result.pendingIntent!!)) - } else { - println("[SyncManager] pull() failed: token null") - _syncEvents.emit(SyncResult.Pull(isSuccess = false)) - } + result?.pendingIntent?.let { + _syncEvents.emit(SyncResult.NeedsAuth(it)) + } ?: _syncEvents.emit(SyncResult.Pull(isSuccess = false, error = "No access token")) return@ioSafe } try { - println("[SyncManager] pull() downloading...") - val json = GoogleDriveApi.download(httpClient, token, BACKUP_FILENAME) - if (json == null) { - println("[SyncManager] pull() failed: json null") - _syncEvents.emit(SyncResult.Pull(isSuccess = false)) + val metaJson = GoogleDriveApi.download(httpClient, token, META_FILE) + if (metaJson == null) { + _syncEvents.emit(SyncResult.Pull(isSuccess = false, error = "No cloud backup found")) + return@ioSafe + } + val remoteMeta = mapper.readValue(metaJson, SyncMetadata::class.java) + + val lastSync = context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) + .getLong(KEY_LAST_SYNC_TIME, 0L) + + if (remoteMeta.updatedAt <= lastSync) { + _syncEvents.emit(SyncResult.Pull(isSuccess = true)) return@ioSafe } - val remoteBackup = mapper.readValue(json, BackupUtils.BackupFile::class.java) - // TODO Phase 3: add real timestamp-based merge instead of naive restore - println("[SyncManager] pull() restoring...") - BackupUtils.restore( - context, - remoteBackup, - restoreSettings = true, - restoreDataStore = true - ) + val prefs = context.getSharedPrefs() + + GoogleDriveApi.download(httpClient, token, SHARD_TRACKING)?.let { json -> + val shard = mapper.readValue(json, SyncShard::class.java) + prefs.edit { + shard.data.forEach { (key, value) -> putString(key, value) } + } + } + + GoogleDriveApi.download(httpClient, token, SHARD_PROGRESS)?.let { json -> + val shard = mapper.readValue(json, SyncShard::class.java) + prefs.edit { + shard.data.forEach { (key, value) -> putString(key, value) } + } + } + context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) .edit().putLong(KEY_LAST_SYNC_TIME, System.currentTimeMillis()).apply() - println("[SyncManager] pull() success") + _syncEvents.emit(SyncResult.Pull(isSuccess = true)) } catch (e: Exception) { - println("[SyncManager] pull() exception: ${e.message}") logError(e) - _syncEvents.emit(SyncResult.Pull(isSuccess = false)) + _syncEvents.emit(SyncResult.Pull(isSuccess = false, error = e.message)) } } @@ -262,18 +232,11 @@ object SyncManager { .getLong(KEY_LAST_SYNC_TIME, 0L) } -/** - * Minimal Google Drive App Data REST v3 implementation using OkHttp. - * - * Uses multipart upload for create, PATCH for update. - * Keeps only the single newest backup file to save quota. - */ internal object GoogleDriveApi { private const val BASE = "https://www.googleapis.com/drive/v3" private const val UPLOAD_BASE = "https://www.googleapis.com/upload/drive/v3" private const val APP_DATA_SPACE = "appDataFolder" - /** Finds the Drive file ID for [filename] in the App Data folder, or null. */ private fun findFileId(client: OkHttpClient, token: String, filename: String): String? { val url = "$BASE/files?spaces=$APP_DATA_SPACE" + "&q=name+%3D+%27$filename%27" + @@ -285,15 +248,15 @@ internal object GoogleDriveApi { return if (files.length() > 0) files.getJSONObject(0).getString("id") else null } - /** - * Upload or overwrite [filename] with [content]. - * Uses a simple multipart request which is well within Drive's 5 MB limit for metadata+JSON. - */ fun upload(client: OkHttpClient, token: String, filename: String, content: String) { val existingId = findFileId(client, token, filename) val boundary = "cs3_sync_boundary" - val meta = """{"name":"$filename","parents":["$APP_DATA_SPACE"]}""" + val meta = if (existingId == null) { + """{"name":"$filename","parents":["$APP_DATA_SPACE"]}""" + } else { + """{"name":"$filename"}""" + } val body = "--$boundary\r\n" + "Content-Type: application/json; charset=UTF-8\r\n\r\n" + "$meta\r\n" + @@ -305,13 +268,11 @@ internal object GoogleDriveApi { val reqBody = body.toRequestBody("multipart/related; boundary=$boundary".toMediaTypeOrNull()) val req = if (existingId == null) { - // Create Request.Builder() - .url("$UPLOAD_BASE/files?uploadType=multipart&spaces=$APP_DATA_SPACE") + .url("$UPLOAD_BASE/files?uploadType=multipart") .addHeader("Authorization", "Bearer $token") .post(reqBody).build() } else { - // Update (PATCH) Request.Builder() .url("$UPLOAD_BASE/files/$existingId?uploadType=multipart") .addHeader("Authorization", "Bearer $token") @@ -320,12 +281,16 @@ internal object GoogleDriveApi { client.newCall(req).execute().use { response -> if (!response.isSuccessful) { - throw Exception("Drive upload failed: ${response.code} ${response.body.string()}") + var errorBody = response.body.string() + try { + val jsonObj = JSONObject(errorBody) + errorBody = jsonObj.optJSONObject("error")?.optString("message") ?: errorBody + } catch (e: Exception) {} + throw Exception("${response.code}: $errorBody") } } } - /** Downloads [filename] content from App Data folder or returns null. */ fun download(client: OkHttpClient, token: String, filename: String): String? { val fileId = findFileId(client, token, filename) ?: return null val req = Request.Builder() diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncUtils.kt new file mode 100644 index 00000000000..565a2b6868a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncUtils.kt @@ -0,0 +1,55 @@ +package com.lagradost.cloudstream3.syncproviders.google + +import com.fasterxml.jackson.annotation.JsonProperty + +data class SyncMetadata( + @JsonProperty("updated_at") val updatedAt: Long = 0, + @JsonProperty("tombstones") val tombstones: Set = emptySet() +) + +data class SyncShard( + @JsonProperty("data") val data: Map = emptyMap(), + @JsonProperty("metadata") val metadata: Map = emptyMap() +) + +object SyncUtils { + fun mergeShards( + localData: Map, + localMetadata: Map, + remoteData: Map, + remoteMetadata: Map, + tombstones: Set + ): Pair, Map> { + val mergedData = localData.toMutableMap() + val mergedMetadata = localMetadata.toMutableMap() + + // Handle remote changes + remoteData.forEach { (key, remoteValue) -> + if (tombstones.contains(key)) { + mergedData.remove(key) + mergedMetadata.remove(key) + return@forEach + } + + val remoteTime = remoteMetadata[key] ?: 0L + val localTime = mergedMetadata[key] ?: 0L + + if (remoteTime > localTime) { + mergedData[key] = remoteValue + mergedMetadata[key] = remoteTime + } + } + + // Apply tombstones to local data + tombstones.forEach { key -> + mergedData.remove(key) + mergedMetadata.remove(key) + } + + return mergedData to mergedMetadata + } + + fun getLocalTimestamp(key: String, metadata: Map): Long { + return metadata[key] ?: 0L + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt index 908ea6b9abb..8bcb9b74018 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt @@ -25,27 +25,19 @@ class SyncSettingsFragment : PreferenceFragmentCompat() { private val credentialManager by lazy { CredentialManager.create(requireContext()) } private val syncResolutionLauncher = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> - println("[SyncSettings] Resolution finished: ${result.resultCode}") if (result.resultCode == android.app.Activity.RESULT_OK) { - // Retry sync logic would go here, or just inform user to try again - println("[SyncSettings] Resolution success, user should retry sync") Toast.makeText(context, R.string.sync_auth_success, Toast.LENGTH_SHORT).show() } } - // ─── Fragment lifecycle ───────────────────────────────────────────────────── - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.settings_sync, rootKey) findPreference("sync_google_drive_connect")?.setOnPreferenceClickListener { - println("[SyncSettings] Connect/Disconnect clicked") val email = SyncManager.getConnectedEmail(requireContext()) if (email == null) { - // Not signed in → launch Credential Manager bottom sheet launchSignIn() } else { - // Already signed in → sign out SyncManager.signOut(requireContext()) updateUiState() Toast.makeText(context, R.string.sync_disconnected_toast, Toast.LENGTH_SHORT).show() @@ -54,14 +46,12 @@ class SyncSettingsFragment : PreferenceFragmentCompat() { } findPreference("sync_push_now")?.setOnPreferenceClickListener { - println("[SyncSettings] Push Now clicked") SyncManager.push(requireContext()) Toast.makeText(context, R.string.sync_push_started, Toast.LENGTH_SHORT).show() true } findPreference("sync_pull_now")?.setOnPreferenceClickListener { - println("[SyncSettings] Pull Now clicked") SyncManager.pull(requireContext()) Toast.makeText(context, R.string.sync_pull_started, Toast.LENGTH_SHORT).show() true @@ -74,35 +64,28 @@ class SyncSettingsFragment : PreferenceFragmentCompat() { lifecycleScope.launch { SyncManager.syncEvents.collectLatest { result -> - println("[SyncSettings] syncEvent received: $result") - if (result is SyncManager.SyncResult.NeedsAuth) { - println("[SyncSettings] Launching auth resolution") try { syncResolutionLauncher.launch( androidx.activity.result.IntentSenderRequest.Builder(result.pendingIntent.intentSender).build() ) } catch (e: Exception) { - println("[SyncSettings] Resolution launch failed: $e") + e.printStackTrace() } return@collectLatest } updateUiState() - val msgRes = when { - result is SyncManager.SyncResult.Push && result.isSuccess -> R.string.sync_push_success - result is SyncManager.SyncResult.Push && !result.isSuccess -> R.string.sync_push_failed - result is SyncManager.SyncResult.Pull && result.isSuccess -> R.string.sync_pull_success - else -> R.string.sync_pull_failed + val msgText = when (result) { + is SyncManager.SyncResult.Push -> if (result.isSuccess) getString(R.string.sync_push_success) else getString(R.string.sync_push_failed) + (result.error?.let { " - $it" } ?: "") + is SyncManager.SyncResult.Pull -> if (result.isSuccess) getString(R.string.sync_pull_success) else getString(R.string.sync_pull_failed) + (result.error?.let { " - $it" } ?: "") + else -> getString(R.string.sync_pull_failed) } - println("[SyncSettings] Target Toast: ${resources.getResourceEntryName(msgRes)}") - Toast.makeText(context, msgRes, Toast.LENGTH_SHORT).show() + Toast.makeText(context, msgText, Toast.LENGTH_LONG).show() } } } - // ─── Sign-in ──────────────────────────────────────────────────────────────── - private fun launchSignIn() { val request = GetCredentialRequest.Builder() .addCredentialOption(SyncManager.buildGoogleIdOption()) @@ -121,8 +104,6 @@ class SyncSettingsFragment : PreferenceFragmentCompat() { } } - // ─── UI helpers ───────────────────────────────────────────────────────────── - private fun updateUiState() { val ctx = context ?: return val email = SyncManager.getConnectedEmail(ctx) @@ -130,24 +111,25 @@ class SyncSettingsFragment : PreferenceFragmentCompat() { val lastSync = SyncManager.getLastSyncTime(ctx) findPreference("sync_google_drive_connect")?.apply { - title = if (isConnected) + title = if (isConnected) { getString(R.string.sync_disconnect_title, email) - else + } else { getString(R.string.sync_connect_title) - summary = if (isConnected) + } + summary = if (isConnected) { getString(R.string.sync_connected_summary, email) - else + } else { getString(R.string.sync_connect_summary) + } } - findPreference("sync_status")?.summary = if (!isConnected) { - getString(R.string.sync_status_not_connected) - } else if (lastSync == 0L) { - getString(R.string.sync_status_never) - } else { - val formatted = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) - .format(Date(lastSync)) - getString(R.string.sync_status_last, formatted) + findPreference("sync_status")?.summary = when { + !isConnected -> getString(R.string.sync_status_not_connected) + lastSync == 0L -> getString(R.string.sync_status_never) + else -> { + val formatted = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(lastSync)) + getString(R.string.sync_status_last, formatted) + } } findPreference("sync_push_now")?.isEnabled = isConnected From 37ae7b6e8bef3863c8de073694753083646d7dde Mon Sep 17 00:00:00 2001 From: ramailo1 Date: Thu, 2 Apr 2026 17:36:19 +0100 Subject: [PATCH 3/4] Minor cleanup: Explicitly handle error body fallback instead of empty catch --- .../syncproviders/google/SyncManager.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt index d54c640e1cd..08753c06932 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt @@ -281,12 +281,14 @@ internal object GoogleDriveApi { client.newCall(req).execute().use { response -> if (!response.isSuccessful) { - var errorBody = response.body.string() - try { - val jsonObj = JSONObject(errorBody) - errorBody = jsonObj.optJSONObject("error")?.optString("message") ?: errorBody - } catch (e: Exception) {} - throw Exception("${response.code}: $errorBody") + val responseBody = response.body.string() + val displayError = try { + val jsonObj = JSONObject(responseBody) + jsonObj.optJSONObject("error")?.optString("message") ?: responseBody + } catch (_: Exception) { + responseBody + } + throw Exception("${response.code}: $displayError") } } } From f3da53f4b169aff1112f2765efd0f20cd9f17dfd Mon Sep 17 00:00:00 2001 From: ramailo1 Date: Thu, 2 Apr 2026 22:09:14 +0100 Subject: [PATCH 4/4] Sharded Sync: Data is split into Tracking, Progress, and Metadata shards for maximum reliability --- .../lagradost/cloudstream3/MainActivity.kt | 5 +- .../cloudstream3/plugins/PluginManager.kt | 91 ++++--- .../cloudstream3/plugins/RepositoryManager.kt | 3 + .../syncproviders/google/SyncManager.kt | 233 ++++++++++++------ .../syncproviders/google/SyncUtils.kt | 58 ++--- .../ui/settings/SyncSettingsFragment.kt | 17 ++ .../cloudstream3/utils/BackupUtils.kt | 29 +-- app/src/main/res/values/strings.xml | 3 + app/src/main/res/xml/settings_sync.xml | 6 + 9 files changed, 266 insertions(+), 179 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index e199b8dc6db..361b4a4492e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -632,7 +632,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa override fun onStart() { super.onStart() - val prefs = PreferenceManager.getDefaultSharedPreferences(this) + val prefs = getSharedPreferences("cs3_sync_prefs", Context.MODE_PRIVATE) + SyncManager.trySilentAuth(this) if (prefs.getBoolean("sync_auto_on_launch", false) && SyncManager.isEnabled(this)) { SyncManager.pull(this) } @@ -670,7 +671,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa override fun onStop() { super.onStop() - val prefs = PreferenceManager.getDefaultSharedPreferences(this) + val prefs = getSharedPreferences("cs3_sync_prefs", Context.MODE_PRIVATE) if (prefs.getBoolean("sync_auto_on_close", false) && SyncManager.isEnabled(this)) { SyncManager.push(this) } 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 ba3357102c7..6001a52b957 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -474,13 +474,56 @@ object PluginManager { } } - /** - * Reloads all local plugins and forces a page update, used for hot reloading with deployWithAdb - * - * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. - * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! - */ @Suppress("FunctionName", "DEPRECATION_ERROR") + + @Throws + suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_restoreSyncPlugins(context: Context) { + assertNonRecursiveCallstack() + Log.d(TAG, "Restoring synced plugins...") + + val onlinePlugins = getPluginsOnline().toList() + Log.d(TAG, "Found ${onlinePlugins.size} plugins in sync list") + + var pluginsChanged = false + + val updatedPlugins = onlinePlugins.amap { savedData -> + val oldFile = File(savedData.filePath) + val parentName = oldFile.parentFile?.name + val fileName = oldFile.name + var currentData = savedData + + if (parentName != null) { + val newFile = File(context.filesDir, "$ONLINE_PLUGINS_FOLDER/$parentName/$fileName") + Log.d(TAG, "Mapping plugin: ${savedData.internalName} -> ${newFile.absolutePath}") + + if (savedData.filePath != newFile.absolutePath) { + currentData = currentData.copy(filePath = newFile.absolutePath) + pluginsChanged = true + } + + if (!newFile.exists() && currentData.url != null) { + Log.d(TAG, "Missing plugin file, downloading: ${currentData.internalName}") + val downloadedFile = downloadPluginToFile( + currentData.url, + newFile + ) + if (downloadedFile == null) { + Log.e(TAG, "Failed to download plugin ${currentData.internalName}") + } + } + } + currentData + } + + if (pluginsChanged) { + setKey(PLUGINS_KEY, updatedPlugins.toTypedArray()) + } + + ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context) + } + + @Suppress("FunctionName", "DEPRECATION_ERROR") + @Throws @Deprecated( "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", @@ -498,14 +541,8 @@ object PluginManager { ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(activity, true) } - /** - * @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins - * and reload all pages even if they are previously valid - * - * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. - * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! - */ @Suppress("FunctionName", "DEPRECATION_ERROR") + @Deprecated( "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", replaceWith = ReplaceWith("loadPlugin"), @@ -526,42 +563,31 @@ object PluginManager { } val sortedPlugins = dir.listFiles() - // Always sort plugins alphabetically for reproducible results + Log.d(TAG, "Found ${sortedPlugins?.size ?: 0} local files in $LOCAL_PLUGINS_PATH") + Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: ${sortedPlugins?.size}") - // Use app-specific external files directory and copy the file there. - // We have to do this because on Android 14+, it otherwise gives SecurityException - // due to dex files and setReadOnly seems to have no effect unless it it here. val pluginDirectory = File(context.getExternalFilesDir(null), "plugins") if (!pluginDirectory.exists()) { - pluginDirectory.mkdirs() // Ensure the plugins directory exists + pluginDirectory.mkdirs() } - // Make sure all local plugins are fully refreshed. removeKey(PLUGINS_KEY_LOCAL) sortedPlugins?.sortedBy { it.name }?.amap { file -> + Log.d(TAG, "Processing local file: ${file.name}") try { val destinationFile = File(pluginDirectory, file.name) - // Only copy the file if the destination file doesn't exist or if it - // has been modified (check file length and modification time). if (!destinationFile.exists() || destinationFile.length() != file.length() || destinationFile.lastModified() != file.lastModified() ) { - - // Copy the file to the app-specific plugin directory file.copyTo(destinationFile, overwrite = true) - - // After copying, set the destination file's modification time - // to match the source file. We do this for performance so that we - // can check the modification time and not make redundant writes. destinationFile.setLastModified(file.lastModified()) } - // Load the plugin after it has been copied maybeLoadPlugin(context, destinationFile) } catch (t: Throwable) { Log.e(TAG, "Failed to copy the file") @@ -578,11 +604,8 @@ object PluginManager { return checkSafeModeFile() || lastError != null } - /** - * This can be used to override any extension loading to fix crashes! - * @return true if safe mode file is present - **/ fun checkSafeModeFile(): Boolean { + return safe { val folder = File(CLOUD_STREAM_FOLDER) if (!folder.exists()) return@safe false @@ -593,9 +616,7 @@ object PluginManager { } ?: false } - /** - * @return True if successful, false if not - * */ + private suspend fun loadPlugin(context: Context, file: File, data: PluginData): Boolean { val fileName = file.nameWithoutExtension val filePath = file.absolutePath 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..30b911e7a48 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.plugins import android.content.Context +import android.util.Log import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey @@ -144,6 +145,8 @@ object RepositoryManager { pluginUrl: String, file: File ): File? { + Log.d("RepositoryManager", "Downloading $pluginUrl to ${file.absolutePath}") + return safeAsync { file.mkdirs() diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt index 08753c06932..05c920a5d34 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt @@ -1,14 +1,19 @@ package com.lagradost.cloudstream3.syncproviders.google +import android.app.Activity import android.app.PendingIntent import android.content.Context +import android.util.Log import androidx.core.content.edit +import androidx.preference.PreferenceManager import com.google.android.gms.auth.api.identity.AuthorizationRequest import com.google.android.gms.auth.api.identity.Identity import com.google.android.gms.common.api.Scope import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.BackupUtils +import com.lagradost.cloudstream3.utils.BackupUtils.isTransferable import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStore import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs @@ -19,6 +24,9 @@ import com.lagradost.cloudstream3.utils.RESULT_SUBSCRIBED_STATE_DATA import com.lagradost.cloudstream3.utils.RESULT_WATCH_STATE import com.lagradost.cloudstream3.utils.RESULT_WATCH_STATE_DATA import com.lagradost.cloudstream3.utils.VIDEO_POS_DUR +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.plugins.RepositoryManager +import com.lagradost.cloudstream3.AutoDownloadMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -32,16 +40,23 @@ import org.json.JSONObject import java.util.concurrent.TimeUnit object SyncManager { - + private const val TAG = "SyncManager" private const val SYNC_PREFS = "cs3_sync_prefs" - private const val KEY_LAST_SYNC_TIME = "last_sync_timestamp" + const val KEY_LAST_SYNC_TIME = "sync_last_time" private const val KEY_IS_ENABLED = "sync_enabled" - private const val KEY_EMAIL = "connected_email" + private const val KEY_EMAIL = "sync_email" + const val KEY_SILENTLY_CONNECTED = "sync_silently_connected" private const val DRIVE_SCOPE_URL = "https://www.googleapis.com/auth/drive.appdata" private const val META_FILE = "sync_meta.json" - private const val SHARD_TRACKING = "shard_tracking.json" - private const val SHARD_PROGRESS = "shard_progress.json" + private const val SHARD_DATASTORE = "shard_datastore.json" + private const val SHARD_SETTINGS = "shard_settings.json" + private const val LEGACY_BACKUP = "cloudstream-backup.json" + + data class Shard( + val version: Int, + val data: Map + ) sealed class SyncResult { data class Push(val isSuccess: Boolean, val error: String? = null) : SyncResult() @@ -57,9 +72,10 @@ object SyncManager { .readTimeout(30, TimeUnit.SECONDS) .build() - fun isEnabled(context: Context): Boolean = - context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) - .getBoolean(KEY_IS_ENABLED, false) + fun isEnabled(context: Context): Boolean { + val prefs = context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) + return prefs.getString(KEY_EMAIL, null) != null || prefs.getBoolean(KEY_SILENTLY_CONNECTED, false) + } fun buildGoogleIdOption(): GetGoogleIdOption = GetGoogleIdOption.Builder() @@ -86,84 +102,93 @@ object SyncManager { .putBoolean(KEY_IS_ENABLED, false) .remove(KEY_EMAIL) .remove(KEY_LAST_SYNC_TIME) + .remove(KEY_SILENTLY_CONNECTED) .apply() } fun getConnectedEmail(context: Context): String? = context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) - .getString(KEY_EMAIL, null) + .getString(KEY_EMAIL, null) ?: if (isEnabled(context)) "Connected Account" else null - private suspend fun getAuthResult(context: Context): com.google.android.gms.auth.api.identity.AuthorizationResult? = - withContext(Dispatchers.IO) { - try { - val authRequest = AuthorizationRequest.builder() - .setRequestedScopes(listOf(Scope(DRIVE_SCOPE_URL))) - .build() - Identity.getAuthorizationClient(context) - .authorize(authRequest) - .await() - } catch (e: Exception) { - null + private suspend fun getAuthResult(context: Context): com.google.android.gms.auth.api.identity.AuthorizationResult? = withContext(Dispatchers.IO) { + try { + val authRequest = AuthorizationRequest.builder() + .setRequestedScopes(listOf(Scope(DRIVE_SCOPE_URL))) + .build() + val result = Identity.getAuthorizationClient(context) + .authorize(authRequest) + .await() + + if (result.accessToken != null) { + context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE).edit { + putBoolean(KEY_SILENTLY_CONNECTED, true) + } } + result + } catch (e: Exception) { + Log.e(TAG, "Auth failed: ${e.message}") + null } + } + + private suspend fun getAuthToken(context: Context): String? { + return getAuthResult(context)?.accessToken + } + + fun trySilentAuth(context: Context) = ioSafe { + if (!isEnabled(context)) return@ioSafe + getAuthToken(context) + } fun push(context: Context) = ioSafe { if (!isEnabled(context)) return@ioSafe - val result = getAuthResult(context) - val token = result?.accessToken - - if (token == null) { - result?.pendingIntent?.let { - _syncEvents.emit(SyncResult.NeedsAuth(it)) - } ?: _syncEvents.emit(SyncResult.Push(isSuccess = false, error = "No access token")) - return@ioSafe - } + val token = getAuthToken(context) ?: return@ioSafe try { - val trackingKeys = listOf( + val datastoreKeys = listOf( RESULT_WATCH_STATE_DATA, RESULT_FAVORITES_STATE_DATA, RESULT_SUBSCRIBED_STATE_DATA, - RESULT_WATCH_STATE + RESULT_WATCH_STATE, + VIDEO_POS_DUR ) - - val prefs = context.getSharedPrefs() - val trackingData = mutableMapOf() - DataStore.run { - trackingKeys.forEach { folder -> + val datastorePrefs = context.getSharedPrefs() + val datastoreMap = mutableMapOf() + datastoreKeys.forEach { folder -> + DataStore.run { context.getKeys("${DataStoreHelper.currentAccount}/$folder").forEach { key -> - prefs.getString(key, null)?.let { - trackingData[key] = it - } + datastorePrefs.getString(key, null)?.let { datastoreMap[key] = it } } } } - val progressData = mutableMapOf() - DataStore.run { - context.getKeys("${DataStoreHelper.currentAccount}/$VIDEO_POS_DUR").forEach { key -> - prefs.getString(key, null)?.let { - progressData[key] = it - } + listOf("PLUGINS_KEY", "REPOSITORIES_KEY").forEach { key -> + if (key.isTransferable(context)) { + datastorePrefs.getString(key, null)?.let { datastoreMap[key] = it } } } - if (trackingData.isNotEmpty()) { - val shard = SyncShard(data = trackingData, metadata = trackingData.mapValues { System.currentTimeMillis() }) - GoogleDriveApi.upload(httpClient, token, SHARD_TRACKING, mapper.writeValueAsString(shard)) - } - if (progressData.isNotEmpty()) { - val shard = SyncShard(data = progressData, metadata = progressData.mapValues { System.currentTimeMillis() }) - GoogleDriveApi.upload(httpClient, token, SHARD_PROGRESS, mapper.writeValueAsString(shard)) + val settingsPrefs = PreferenceManager.getDefaultSharedPreferences(context) + val settingsMap = mutableMapOf() + settingsPrefs.all.forEach { (k, v) -> + if (v != null && k.isTransferable(context)) { + settingsMap[k] = v + } } - val meta = SyncMetadata(updatedAt = System.currentTimeMillis()) + Log.d(TAG, "Pushing shards: datastore(${datastoreMap.size} keys), settings(${settingsMap.size} keys)") + val now = System.currentTimeMillis() + GoogleDriveApi.upload(httpClient, token, SHARD_DATASTORE, mapper.writeValueAsString(Shard(1, datastoreMap))) + GoogleDriveApi.upload(httpClient, token, SHARD_SETTINGS, mapper.writeValueAsString(Shard(1, settingsMap))) + + val meta = SyncMetadata(updatedAt = now) GoogleDriveApi.upload(httpClient, token, META_FILE, mapper.writeValueAsString(meta)) + Log.d(TAG, "Push successful at $now") context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) - .edit().putLong(KEY_LAST_SYNC_TIME, System.currentTimeMillis()).apply() + .edit().putLong(KEY_LAST_SYNC_TIME, now).apply() _syncEvents.emit(SyncResult.Push(isSuccess = true)) } catch (e: Exception) { @@ -173,52 +198,62 @@ object SyncManager { } fun pull(context: Context) = ioSafe { - if (!isEnabled(context)) return@ioSafe - - val result = getAuthResult(context) - val token = result?.accessToken - - if (token == null) { - result?.pendingIntent?.let { - _syncEvents.emit(SyncResult.NeedsAuth(it)) - } ?: _syncEvents.emit(SyncResult.Pull(isSuccess = false, error = "No access token")) - return@ioSafe - } + val token = getAuthToken(context) ?: return@ioSafe + Log.d(TAG, "Starting pull...") try { val metaJson = GoogleDriveApi.download(httpClient, token, META_FILE) if (metaJson == null) { - _syncEvents.emit(SyncResult.Pull(isSuccess = false, error = "No cloud backup found")) + Log.d(TAG, "No metadata found, checking legacy backup...") + val legacyJson = GoogleDriveApi.download(httpClient, token, LEGACY_BACKUP) + if (legacyJson != null) { + Log.d(TAG, "Found legacy backup, converting...") + val legacyBackup = mapper.readValue(legacyJson, BackupUtils.BackupFile::class.java) + val (dataShard, settingsShard) = SyncUtils.convertLegacyToShards(legacyBackup) + applyShard(context, dataShard, isDataStore = true) + applyShard(context, settingsShard, isDataStore = false) + Log.d(TAG, "Legacy migration successful") + _syncEvents.emit(SyncResult.Pull(isSuccess = true)) + return@ioSafe + } + Log.d(TAG, "No remote data found at all") + _syncEvents.emit(SyncResult.Pull(isSuccess = false, error = "No backup found on cloud")) return@ioSafe } + val remoteMeta = mapper.readValue(metaJson, SyncMetadata::class.java) - val lastSync = context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) .getLong(KEY_LAST_SYNC_TIME, 0L) + Log.d(TAG, "Cloud update time: ${remoteMeta.updatedAt}, Local sync time: $lastSync") if (remoteMeta.updatedAt <= lastSync) { + Log.d(TAG, "Local version is up to date") _syncEvents.emit(SyncResult.Pull(isSuccess = true)) return@ioSafe } - val prefs = context.getSharedPrefs() - - GoogleDriveApi.download(httpClient, token, SHARD_TRACKING)?.let { json -> - val shard = mapper.readValue(json, SyncShard::class.java) - prefs.edit { - shard.data.forEach { (key, value) -> putString(key, value) } - } + Log.d(TAG, "Pulling new shards...") + GoogleDriveApi.download(httpClient, token, SHARD_DATASTORE)?.let { json -> + val shard = parseShard(json) + Log.d(TAG, "Applying datastore shard (${shard.data.size} keys)") + applyShard(context, shard, isDataStore = true) } - GoogleDriveApi.download(httpClient, token, SHARD_PROGRESS)?.let { json -> - val shard = mapper.readValue(json, SyncShard::class.java) - prefs.edit { - shard.data.forEach { (key, value) -> putString(key, value) } - } + GoogleDriveApi.download(httpClient, token, SHARD_SETTINGS)?.let { json -> + val shard = parseShard(json) + Log.d(TAG, "Applying settings shard (${shard.data.size} keys)") + applyShard(context, shard, isDataStore = false) + } + + try { + @Suppress("DEPRECATION_ERROR") + PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_restoreSyncPlugins(context) + } catch (e: Exception) { + Log.e(TAG, "Plugin restore failed: ${e.message}") } context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) - .edit().putLong(KEY_LAST_SYNC_TIME, System.currentTimeMillis()).apply() + .edit().putLong(KEY_LAST_SYNC_TIME, remoteMeta.updatedAt).apply() _syncEvents.emit(SyncResult.Pull(isSuccess = true)) } catch (e: Exception) { @@ -227,6 +262,44 @@ object SyncManager { } } + private fun parseShard(json: String): Shard { + val obj = JSONObject(json) + val dataObj = obj.getJSONObject("data") + val dataMap = mutableMapOf() + dataObj.keys().forEach { key -> + dataMap[key] = dataObj.get(key) + } + return Shard(obj.getInt("version"), dataMap) + } + + private fun applyShard(context: Context, shard: Shard, isDataStore: Boolean) { + val prefs = if (isDataStore) context.getSharedPreferences("rebuild_preference", Context.MODE_PRIVATE) + else PreferenceManager.getDefaultSharedPreferences(context) + prefs.edit { + shard.data.forEach { (key, value) -> + when (value) { + is Boolean -> putBoolean(key, value) + is Int -> putInt(key, value) + is Long -> putLong(key, value) + is Float -> putFloat(key, value) + is String -> putString(key, value) + else -> putString(key, value.toString()) + } + } + } + } + + suspend fun getCloudMetadata(context: Context): SyncMetadata? = withContext(Dispatchers.IO) { + val token = getAuthToken(context) ?: return@withContext null + try { + GoogleDriveApi.download(httpClient, token, META_FILE)?.let { + mapper.readValue(it, SyncMetadata::class.java) + } + } catch (_: Exception) { + null + } + } + fun getLastSyncTime(context: Context): Long = context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) .getLong(KEY_LAST_SYNC_TIME, 0L) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncUtils.kt index 565a2b6868a..1628a42940e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncUtils.kt @@ -1,55 +1,29 @@ package com.lagradost.cloudstream3.syncproviders.google import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.utils.BackupUtils data class SyncMetadata( - @JsonProperty("updated_at") val updatedAt: Long = 0, - @JsonProperty("tombstones") val tombstones: Set = emptySet() -) - -data class SyncShard( - @JsonProperty("data") val data: Map = emptyMap(), - @JsonProperty("metadata") val metadata: Map = emptyMap() + @JsonProperty("updated_at") val updatedAt: Long = 0 ) object SyncUtils { - fun mergeShards( - localData: Map, - localMetadata: Map, - remoteData: Map, - remoteMetadata: Map, - tombstones: Set - ): Pair, Map> { - val mergedData = localData.toMutableMap() - val mergedMetadata = localMetadata.toMutableMap() - - // Handle remote changes - remoteData.forEach { (key, remoteValue) -> - if (tombstones.contains(key)) { - mergedData.remove(key) - mergedMetadata.remove(key) - return@forEach - } - - val remoteTime = remoteMetadata[key] ?: 0L - val localTime = mergedMetadata[key] ?: 0L - - if (remoteTime > localTime) { - mergedData[key] = remoteValue - mergedMetadata[key] = remoteTime - } - } - - // Apply tombstones to local data - tombstones.forEach { key -> - mergedData.remove(key) - mergedMetadata.remove(key) + fun convertLegacyToShards(backup: BackupUtils.BackupFile): Pair { + val datastoreData = mutableMapOf() + val settingsData = mutableMapOf() + + fun flatten(vars: BackupUtils.BackupVars?, target: MutableMap) { + vars?.bool?.forEach { (k, v) -> target[k] = v } + vars?.int?.forEach { (k, v) -> target[k] = v } + vars?.string?.forEach { (k, v) -> target[k] = v } + vars?.float?.forEach { (k, v) -> target[k] = v } + vars?.long?.forEach { (k, v) -> target[k] = v } + vars?.stringSet?.forEach { (k, v) -> target[k] = v ?: emptySet() } } - return mergedData to mergedMetadata - } + flatten(backup.datastore, datastoreData) + flatten(backup.settings, settingsData) - fun getLocalTimestamp(key: String, metadata: Map): Long { - return metadata[key] ?: 0L + return SyncManager.Shard(1, datastoreData) to SyncManager.Shard(1, settingsData) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt index 8bcb9b74018..fe91a3ebcde 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt @@ -31,6 +31,7 @@ class SyncSettingsFragment : PreferenceFragmentCompat() { } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + preferenceManager.sharedPreferencesName = "cs3_sync_prefs" setPreferencesFromResource(R.xml.settings_sync, rootKey) findPreference("sync_google_drive_connect")?.setOnPreferenceClickListener { @@ -132,6 +133,22 @@ class SyncSettingsFragment : PreferenceFragmentCompat() { } } + val cloudPref = findPreference("sync_cloud_status") + cloudPref?.isVisible = isConnected + if (isConnected) { + cloudPref?.summary = getString(R.string.sync_status_cloud_fetching) + lifecycleScope.launch { + val meta = SyncManager.getCloudMetadata(ctx) + val status = if (meta != null) { + val formatted = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(meta.updatedAt)) + getString(R.string.sync_status_cloud, formatted) + } else { + getString(R.string.sync_status_cloud_none) + } + cloudPref?.summary = status + } + } + findPreference("sync_push_now")?.isEnabled = isConnected findPreference("sync_pull_now")?.isEnabled = isConnected findPreference("sync_auto_on_launch")?.isEnabled = isConnected 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 c4260843051..b9a56112b54 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -47,32 +47,26 @@ import java.util.Locale object BackupUtils { - /** - * No sensitive or breaking data in the backup - * */ private val nonTransferableKeys = listOf( ANILIST_CACHED_LIST, MAL_CACHED_LIST, KITSU_CACHED_LIST, - // The plugins themselves are not backed up + PLUGINS_KEY, PLUGINS_KEY_LOCAL, AccountManager.ACCOUNT_TOKEN, AccountManager.ACCOUNT_IDS, - "biometric_key", // can lock down users if backup is shared on a incompatible device - "nginx_user", // Nginx user key + "biometric_key", + "nginx_user", - // No access rights after restore data from backup "download_path_key", "download_path_key_visual", "backup_path_key", "backup_dir_path_key", - // When sharing backup we do not want to transfer what is essentially the password - // Note that this is deprecated, and can be removed after all tokens have expired "anilist_token", "anilist_user", "mal_user", @@ -84,30 +78,25 @@ object BackupUtils { "simkl_token", - // Downloads can not be restored from backups. - // The download path URI can not be transferred. - // In the future we may potentially write metadata to files in the download directory - // and make it possible to restore download folders using that metadata. + DOWNLOAD_EPISODE_CACHE_BACKUP, DOWNLOAD_EPISODE_CACHE, - // Download headers are unintuitively used in the resume watching system. - // We can therefore not prune download headers in backups. //DOWNLOAD_HEADER_CACHE_BACKUP, //DOWNLOAD_HEADER_CACHE, - // This may overwrite valid local data with invalid data + KEY_DOWNLOAD_INFO, - // Prevent backups from automatically starting downloads + KEY_RESUME_IN_QUEUE, KEY_RESUME_PACKAGES, QUEUE_KEY ) - /** false if key should not be contained in backup */ - private fun String.isTransferable(context: Context): Boolean { + + fun String.isTransferable(context: Context): Boolean { val pluginSyncEnabled = context.getDefaultSharedPrefs().getBoolean("sync_plugins_enabled", false) val excluded = if (pluginSyncEnabled) { nonTransferableKeys.filter { it != PLUGINS_KEY } @@ -119,7 +108,7 @@ object BackupUtils { private var restoreFileSelector: ActivityResultLauncher>? = null - // Kinda hack, but I couldn't think of a better way + data class BackupVars( @JsonProperty("_Bool") val bool: Map?, @JsonProperty("_Int") val int: Map?, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cf4c52c04ca..e760f9d3bab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -810,4 +810,7 @@ ✓ Download from cloud successful ✗ Download from cloud failed Google Drive authorized! You can now sync. + Last Cloud Backup: %s + Fetching cloud status... + No cloud backup found diff --git a/app/src/main/res/xml/settings_sync.xml b/app/src/main/res/xml/settings_sync.xml index 5db04a8d4d8..11fa9ec9059 100644 --- a/app/src/main/res/xml/settings_sync.xml +++ b/app/src/main/res/xml/settings_sync.xml @@ -15,6 +15,12 @@ android:summary="@string/sync_status_not_connected" android:title="@string/sync_status_title" /> + +