diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 97d24c4fbd0..33d2bb29420 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -98,6 +98,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" } @@ -238,6 +243,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..361b4a4492e 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,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } + override fun onStart() { + super.onStart() + 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) + } + } + override fun onResume() { super.onResume() afterPluginsLoadedEvent += ::onAllPluginsLoaded @@ -658,6 +669,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } } + override fun onStop() { + super.onStop() + val prefs = getSharedPreferences("cs3_sync_prefs", Context.MODE_PRIVATE) + 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/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 new file mode 100644 index 00000000000..05c920a5d34 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncManager.kt @@ -0,0 +1,379 @@ +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 +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 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 +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 + +object SyncManager { + private const val TAG = "SyncManager" + private const val SYNC_PREFS = "cs3_sync_prefs" + const val KEY_LAST_SYNC_TIME = "sync_last_time" + private const val KEY_IS_ENABLED = "sync_enabled" + 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_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() + data class Pull(val isSuccess: Boolean, val error: String? = null) : SyncResult() + data class NeedsAuth(val pendingIntent: PendingIntent) : SyncResult() + } + + private val _syncEvents = MutableSharedFlow(replay = 0, extraBufferCapacity = 1) + val syncEvents = _syncEvents.asSharedFlow() + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + 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() + .setFilterByAuthorizedAccounts(false) + .setAutoSelectEnabled(false) + .apply { + if (BuildConfig.GOOGLE_CLIENT_ID.isNotBlank()) { + setServerClientId(BuildConfig.GOOGLE_CLIENT_ID) + } + } + .build() + + 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) + .remove(KEY_SILENTLY_CONNECTED) + .apply() + } + + fun getConnectedEmail(context: Context): String? = + context.getSharedPreferences(SYNC_PREFS, Context.MODE_PRIVATE) + .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() + 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 token = getAuthToken(context) ?: return@ioSafe + + try { + val datastoreKeys = listOf( + RESULT_WATCH_STATE_DATA, + RESULT_FAVORITES_STATE_DATA, + RESULT_SUBSCRIBED_STATE_DATA, + RESULT_WATCH_STATE, + VIDEO_POS_DUR + ) + val datastorePrefs = context.getSharedPrefs() + val datastoreMap = mutableMapOf() + datastoreKeys.forEach { folder -> + DataStore.run { + context.getKeys("${DataStoreHelper.currentAccount}/$folder").forEach { key -> + datastorePrefs.getString(key, null)?.let { datastoreMap[key] = it } + } + } + } + + listOf("PLUGINS_KEY", "REPOSITORIES_KEY").forEach { key -> + if (key.isTransferable(context)) { + datastorePrefs.getString(key, null)?.let { datastoreMap[key] = it } + } + } + + + val settingsPrefs = PreferenceManager.getDefaultSharedPreferences(context) + val settingsMap = mutableMapOf() + settingsPrefs.all.forEach { (k, v) -> + if (v != null && k.isTransferable(context)) { + settingsMap[k] = v + } + } + + 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, now).apply() + + _syncEvents.emit(SyncResult.Push(isSuccess = true)) + } catch (e: Exception) { + logError(e) + _syncEvents.emit(SyncResult.Push(isSuccess = false, error = e.message)) + } + } + + fun pull(context: Context) = ioSafe { + val token = getAuthToken(context) ?: return@ioSafe + Log.d(TAG, "Starting pull...") + + try { + val metaJson = GoogleDriveApi.download(httpClient, token, META_FILE) + if (metaJson == null) { + 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 + } + + 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_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, remoteMeta.updatedAt).apply() + + _syncEvents.emit(SyncResult.Pull(isSuccess = true)) + } catch (e: Exception) { + logError(e) + _syncEvents.emit(SyncResult.Pull(isSuccess = false, error = e.message)) + } + } + + 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) +} + +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" + + 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 + } + + fun upload(client: OkHttpClient, token: String, filename: String, content: String) { + val existingId = findFileId(client, token, filename) + + val boundary = "cs3_sync_boundary" + 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" + + "--$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) { + Request.Builder() + .url("$UPLOAD_BASE/files?uploadType=multipart") + .addHeader("Authorization", "Bearer $token") + .post(reqBody).build() + } else { + 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) { + 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") + } + } + } + + 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/syncproviders/google/SyncUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncUtils.kt new file mode 100644 index 00000000000..1628a42940e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/google/SyncUtils.kt @@ -0,0 +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 +) + +object SyncUtils { + 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() } + } + + flatten(backup.datastore, datastoreData) + flatten(backup.settings, settingsData) + + return SyncManager.Shard(1, datastoreData) to SyncManager.Shard(1, settingsData) + } +} 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..fe91a3ebcde --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SyncSettingsFragment.kt @@ -0,0 +1,157 @@ +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 -> + if (result.resultCode == android.app.Activity.RESULT_OK) { + Toast.makeText(context, R.string.sync_auth_success, Toast.LENGTH_SHORT).show() + } + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + preferenceManager.sharedPreferencesName = "cs3_sync_prefs" + setPreferencesFromResource(R.xml.settings_sync, rootKey) + + findPreference("sync_google_drive_connect")?.setOnPreferenceClickListener { + val email = SyncManager.getConnectedEmail(requireContext()) + if (email == null) { + launchSignIn() + } else { + SyncManager.signOut(requireContext()) + updateUiState() + Toast.makeText(context, R.string.sync_disconnected_toast, Toast.LENGTH_SHORT).show() + } + true + } + + findPreference("sync_push_now")?.setOnPreferenceClickListener { + SyncManager.push(requireContext()) + Toast.makeText(context, R.string.sync_push_started, Toast.LENGTH_SHORT).show() + true + } + + findPreference("sync_pull_now")?.setOnPreferenceClickListener { + 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 -> + if (result is SyncManager.SyncResult.NeedsAuth) { + try { + syncResolutionLauncher.launch( + androidx.activity.result.IntentSenderRequest.Builder(result.pendingIntent.intentSender).build() + ) + } catch (e: Exception) { + e.printStackTrace() + } + return@collectLatest + } + + updateUiState() + 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) + } + Toast.makeText(context, msgText, Toast.LENGTH_LONG).show() + } + } + } + + 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() + } + } + } + + 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 = 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) + } + } + + 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 + 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..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,36 +78,37 @@ 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(): Boolean { - return !nonTransferableKeys.any { this.contains(it) } + + 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 - // Kinda hack, but I couldn't think of a better way + data class BackupVars( @JsonProperty("_Bool") val bool: Map?, @JsonProperty("_Int") val int: Map?, @@ -129,11 +124,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 +302,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. + 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 new file mode 100644 index 00000000000..11fa9ec9059 --- /dev/null +++ b/app/src/main/res/xml/settings_sync.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 c79726d0104..9176e1bc088 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" }