From 1122137d1887925888c41806170f58a7f80834c9 Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Mon, 3 Apr 2023 21:43:51 +0200 Subject: [PATCH 01/37] feat: add remote sync capability - POC --- app/build.gradle.kts | 40 +- .../drawable/ic_baseline_add_to_drive_24.xml | 5 + app/src/main/AndroidManifest.xml | 11 + .../syncproviders/AccountManager.kt | 22 +- .../cloudstream3/syncproviders/BackupAPI.kt | 36 ++ .../syncproviders/InAppAuthAPI.kt | 5 - .../syncproviders/InAppOAuth2API.kt | 63 +++ .../syncproviders/providers/GoogleDriveApi.kt | 396 ++++++++++++++++++ .../ui/settings/SettingsAccount.kt | 135 +----- .../helpers/settings/account/DialogBuilder.kt | 130 ++++++ .../account/InAppAuthDialogBuilder.kt | 100 +++++ .../account/InAppOAuth2DialogBuilder.kt | 68 +++ .../cloudstream3/utils/BackupUtils.kt | 4 +- .../lagradost/cloudstream3/utils/DataStore.kt | 9 +- .../res/layout/add_account_input_oauth.xml | 145 +++++++ app/src/main/res/values/strings.xml | 11 + app/src/main/res/xml/settings_account.xml | 13 +- 17 files changed, 1051 insertions(+), 142 deletions(-) create mode 100644 app/src/debug/res/drawable/ic_baseline_add_to_drive_24.xml create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt create mode 100644 app/src/main/res/layout/add_account_input_oauth.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0bd56fe7..502a3ec0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,8 @@ import com.android.build.gradle.api.BaseVariantOutput import org.jetbrains.dokka.gradle.DokkaTask import java.io.ByteArrayOutputStream import java.net.URL +import java.util.Properties +import java.io.FileInputStream plugins { id("com.android.application") @@ -19,11 +21,14 @@ fun String.execute() = ByteArrayOutputStream().use { baot -> workingDir = projectDir commandLine = this@execute.split(Regex("\\s")) standardOutput = baot - }.exitValue == 0) + }.exitValue == 0) String(baot.toByteArray()).trim() else null } +val localProperties = Properties() +localProperties.load(FileInputStream(rootProject.file("local.properties"))) + android { testOptions { unitTests.isReturnDefaultValues = true @@ -79,7 +84,20 @@ android { debug { isDebuggable = true applicationIdSuffix = ".debug" - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + resValue( + "string", + "debug_gdrive_secret", + localProperties.getProperty("debug.gdrive.secret") ?: "" + ) + resValue( + "string", + "debug_gdrive_clientId", + localProperties.getProperty("debug.gdrive.clientId") ?: "" + ) } } flavorDimensions.add("state") @@ -225,8 +243,26 @@ dependencies { // color pallette for images -> colors implementation("androidx.palette:palette-ktx:1.0.0") + + implementation("androidx.browser:browser:1.4.0") + implementation("com.google.api-client:google-api-client:2.0.0") { + exclude( + group = "org.apache.httpcomponents", + ) + } + implementation("com.google.oauth-client:google-oauth-client-jetty:1.34.1") { + exclude( + group = "org.apache.httpcomponents", + ) + } + implementation("com.google.apis:google-api-services-drive:v3-rev20220815-2.0.0") { + exclude( + group = "org.apache.httpcomponents", + ) + } } + tasks.register("androidSourcesJar", Jar::class) { archiveClassifier.set("sources") from(android.sourceSets.getByName("main").java.srcDirs) //full sources diff --git a/app/src/debug/res/drawable/ic_baseline_add_to_drive_24.xml b/app/src/debug/res/drawable/ic_baseline_add_to_drive_24.xml new file mode 100644 index 00000000..6a717cb3 --- /dev/null +++ b/app/src/debug/res/drawable/ic_baseline_add_to_drive_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 563c82f8..a75b9069 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -159,6 +159,17 @@ android:pathPrefix="/" android:scheme="https" /> + + + + + + + + + + + ( - malApi, aniListApi + malApi, aniListApi, googleDriveApi ) // this needs init with context and can be accessed in settings val accountManagers get() = listOf( - malApi, aniListApi, openSubtitlesApi, //nginxApi + malApi, aniListApi, openSubtitlesApi, googleDriveApi //, nginxApi ) // used for active syncing @@ -33,8 +35,16 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi) ) + // used for active backup + val BackupApis + get() = listOf>( + googleDriveApi + ) + val inAppAuths - get() = listOf(openSubtitlesApi)//, nginxApi) + get() = listOf( + openSubtitlesApi, googleDriveApi//, nginxApi + ) val subtitleProviders get() = listOf( @@ -89,6 +99,12 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { // int array of all accounts indexes private val accountsKey get() = "${idPrefix}_accounts" + + // runs on startup + @WorkerThread + open suspend fun initialize() { + } + protected fun removeAccountKeys() { removeKeys(accountId) val accounts = getAccounts()?.toMutableList() ?: mutableListOf() diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt new file mode 100644 index 00000000..b184117b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -0,0 +1,36 @@ +package com.lagradost.cloudstream3.syncproviders + +import android.content.Context +import com.lagradost.cloudstream3.mvvm.launchSafe +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds + +interface BackupAPI { + private companion object { + val DEBOUNCE_TIME_MS = 15.seconds + } + + fun downloadSyncData() + fun uploadSyncData() + fun shouldUpdate(): Boolean + + fun Context.mergeBackup(incomingData: String) + fun Context.createBackup(loginData: LOGIN_DATA) + + var uploadJob: Job? + fun addToQueue() { + if (!shouldUpdate()) { + return + } + + uploadJob?.cancel() + uploadJob = CoroutineScope(Dispatchers.IO).launchSafe { + delay(DEBOUNCE_TIME_MS) + uploadSyncData() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt index 8b6fdf46..e17e7ccc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt @@ -35,11 +35,6 @@ abstract class InAppAuthAPIManager(defIndex: Int) : AccountManager(defIndex), In override val storesPasswordInPlainText = true override val requiresLogin = true - // runs on startup - @WorkerThread - open suspend fun initialize() { - } - override fun logOut() { throw NotImplementedError() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt new file mode 100644 index 00000000..5af8ff51 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt @@ -0,0 +1,63 @@ +package com.lagradost.cloudstream3.syncproviders + +import androidx.fragment.app.FragmentActivity +import com.fasterxml.jackson.annotation.JsonIgnore +import com.lagradost.cloudstream3.AcraApplication + +interface InAppOAuth2API : OAuth2API { + data class LoginData( + val secret: String, + val clientId: String, + val redirectUrl: String, + val fileNameInput: String, + var syncFileId: String? + ) { + @JsonIgnore + val fileName = fileNameInput.replace(Regex("[^a-zA-Z0-9.\\-_]"), "") + ".json" + } + + // this is for displaying the UI + val requiresFilename: Boolean + val requiresSecret: Boolean + val requiresClientId: Boolean + + val defaultFilenameValue: String + val defaultRedirectUrl: String + + + // should launch intent to acquire token + suspend fun getAuthorizationToken(activity: FragmentActivity, data: LoginData) + + // used to fill the UI if you want to edit any data about your login info + fun getLatestLoginData(): LoginData? +} + +abstract class InAppOAuth2APIManager(defIndex: Int) : AccountManager(defIndex), InAppOAuth2API { + enum class K { + LOGIN_DATA, + TOKEN; + + val value: String = "data_oauth2_$name" + } + + protected fun storeValue(key: K, value: T) = AcraApplication.setKey( + accountId, key.value, value + ) + + protected fun clearValue(key: K) = AcraApplication.removeKey( + accountId, key.value + ) + + protected inline fun getValue(key: K) = AcraApplication.getKey( + accountId, key.value + ) + + override val requiresLogin = true + override val createAccountUrl = null + + override fun logOut() { + K.values().forEach { clearValue(it) } + removeAccountKeys() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt new file mode 100644 index 00000000..10ab64b4 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -0,0 +1,396 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import android.content.Context +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import androidx.fragment.app.FragmentActivity +import com.fasterxml.jackson.module.kotlin.readValue +import com.google.api.client.auth.oauth2.AuthorizationCodeFlow +import com.google.api.client.auth.oauth2.Credential +import com.google.api.client.auth.oauth2.TokenResponse +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport +import com.google.api.client.http.FileContent +import com.google.api.client.json.gson.GsonFactory +import com.google.api.client.util.store.MemoryDataStoreFactory +import com.google.api.services.drive.Drive +import com.google.api.services.drive.DriveScopes +import com.google.api.services.drive.model.File +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.launchSafe +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.BackupAPI +import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API +import com.lagradost.cloudstream3.syncproviders.InAppOAuth2APIManager +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.BackupUtils +import com.lagradost.cloudstream3.utils.BackupUtils.getBackup +import com.lagradost.cloudstream3.utils.BackupUtils.restore +import com.lagradost.cloudstream3.utils.DataStore +import com.lagradost.cloudstream3.utils.DataStore.removeKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import java.io.InputStream +import java.util.* + + +// TODO: improvements and ideas +// - add option to use proper oauth through google services one tap - would need google console project on behalf of cloudstream +// - encrypt data on drive +// - choose what should be synced +// - having a button to run sync would be nice +class GoogleDriveApi(index: Int) : + InAppOAuth2APIManager(index), + BackupAPI { + ///////////////////////////////////////// + ///////////////////////////////////////// + // Setup + override val key = "gdrive" + override val redirectUrl = "oauth/google-drive" + + override val idPrefix = "gdrive" + override val name = "Google Drive" + override val icon = R.drawable.ic_baseline_add_to_drive_24 + + override val requiresFilename = true + override val requiresSecret = true + override val requiresClientId = true + override val defaultFilenameValue = "cloudstreamapp-sync-file" + override val defaultRedirectUrl = + "https://chiff.github.io/cloudstream-sync/google-drive" // TODO: we should move this one to cs3 repo + + override var uploadJob: Job? = null + + var tempAuthFlow: AuthorizationCodeFlow? = null + var lastBackupJson: String? = null + + var continuousDownloadJob: Job? = null + + ///////////////////////////////////////// + ///////////////////////////////////////// + // OAuth2API implementation + override fun authenticate(activity: FragmentActivity?) { + // this was made for direct authentication for OAuth2 + throw IllegalStateException("Authenticate should not be called") + } + + override suspend fun handleRedirect(url: String): Boolean { + val flow = tempAuthFlow + val data = getValue(K.LOGIN_DATA) + + if (flow == null || data == null) { + return false + } + + val uri = Uri.parse(url) + val code = uri.getQueryParameter("code") + + val googleTokenResponse = try { + flow.newTokenRequest(code) + .setRedirectUri(data.redirectUrl) + .execute() + } catch (e: Exception) { + switchToOldAccount() + return false + } + + flow.createAndStoreCredential( + googleTokenResponse, + data.clientId + ) + + storeValue(K.TOKEN, googleTokenResponse) + startContinuousDownload() + + tempAuthFlow = null + return true + } + + ///////////////////////////////////////// + ///////////////////////////////////////// + // InAppOAuth2APIManager implementation + override suspend fun initialize() { + if (loginInfo() == null) { + return + } + + startContinuousDownload() + } + + private fun startContinuousDownload() { + continuousDownloadJob?.cancel() + continuousDownloadJob = CoroutineScope(Dispatchers.IO).launchSafe { + if (uploadJob?.isActive == true) { + uploadJob!!.invokeOnCompletion { + startContinuousDownload() + } + } else { + downloadSyncData() + delay(1000 * 60) + startContinuousDownload() + } + } + } + + override fun loginInfo(): AuthAPI.LoginInfo? { + getCredentialsFromStore() ?: return null + + return AuthAPI.LoginInfo( + name = "google-account-$accountIndex", + accountIndex = accountIndex + ) + } + + ///////////////////////////////////////// + ///////////////////////////////////////// + // InAppOAuth2API implementation + override suspend fun getAuthorizationToken( + activity: FragmentActivity, + data: InAppOAuth2API.LoginData + ) { + val credential = loginInfo() + if (credential != null) { + switchToNewAccount() + } + + storeValue(K.LOGIN_DATA, data) + + val authFlow = GAPI.createAuthFlow(data.clientId, data.secret) + this.tempAuthFlow = authFlow + + try { + registerAccount() + + val url = authFlow.newAuthorizationUrl().setRedirectUri(data.redirectUrl).build() + val customTabIntent = CustomTabsIntent.Builder().setShowTitle(true).build() + customTabIntent.launchUrl(activity, Uri.parse(url)) + } catch (e: Exception) { + switchToOldAccount() + CommonActivity.showToast( + activity, + activity.getString(R.string.authenticated_user_fail).format(name) + ) + } + } + + override fun getLatestLoginData(): InAppOAuth2API.LoginData? { + return getValue(K.LOGIN_DATA) + } + + ///////////////////////////////////////// + ///////////////////////////////////////// + // BackupAPI implementation + override fun Context.mergeBackup(incomingData: String) { + val currentData = getBackup() + val newData = DataStore.mapper.readValue(incomingData) + + getRedundantKeys(currentData, newData).forEach { + removeKey(it) + } + + + restore( + newData, + restoreSettings = true, + restoreDataStore = true + ) + + } + + // 🤮 + private fun getRedundantKeys( + old: BackupUtils.BackupFile, + new: BackupUtils.BackupFile + ): List = mutableListOf( + *getRedundant(old.settings._Bool, new.settings._Bool), + *getRedundant(old.settings._Long, new.settings._Long), + *getRedundant(old.settings._Float, new.settings._Float), + *getRedundant(old.settings._Int, new.settings._Int), + *getRedundant(old.settings._String, new.settings._String), + *getRedundant(old.settings._StringSet, new.settings._StringSet), + *getRedundant(old.datastore._Bool, new.datastore._Bool), + *getRedundant(old.datastore._Long, new.datastore._Long), + *getRedundant(old.datastore._Float, new.datastore._Float), + *getRedundant(old.datastore._Int, new.datastore._Int), + *getRedundant(old.datastore._String, new.datastore._String), + *getRedundant(old.datastore._StringSet, new.datastore._StringSet), + ) + + private fun getRedundant(old: Map?, new: Map?): Array = + old.orEmpty().keys.subtract(new.orEmpty().keys).toTypedArray() + + override fun Context.createBackup(loginData: InAppOAuth2API.LoginData) { + val drive = getDriveService()!! + + val fileName = loginData.fileName + val ioFile = java.io.File(AcraApplication.context?.cacheDir, fileName) + lastBackupJson = getBackup().toJson() + ioFile.writeText(lastBackupJson!!) + + val fileMetadata = File() + fileMetadata.name = fileName + fileMetadata.mimeType = "application/json" + val fileContent = FileContent("application/json", ioFile) + + val fileId = getOrCreateSyncFileId(drive, loginData) + if (fileId != null) { + try { + val file = drive.files().update(fileId, fileMetadata, fileContent).execute() + loginData.syncFileId = file.id + } catch (_: Exception) { + val file = drive.files().create(fileMetadata, fileContent).execute() + loginData.syncFileId = file.id + } + } else { + val file = drive.files().create(fileMetadata, fileContent).execute() + loginData.syncFileId = file.id + } + + storeValue(K.LOGIN_DATA, loginData) + } + + override fun downloadSyncData() { + val ctx = AcraApplication.context ?: return + val drive = getDriveService() ?: return + val loginData = getLatestLoginData() ?: return + + val existingFileId = getOrCreateSyncFileId(drive, loginData) + val existingFile = if (existingFileId != null) { + try { + drive.files().get(existingFileId) + } catch (_: Exception) { + null + } + } else { + null + } + + if (existingFile != null) { + try { + val inputStream: InputStream = existingFile.executeMediaAsInputStream() + val content: String = inputStream.bufferedReader().use { it.readText() } + ctx.mergeBackup(content) + return + } catch (_: Exception) { + } + } else { + uploadSyncData() + } + } + + private fun getOrCreateSyncFileId(drive: Drive, loginData: InAppOAuth2API.LoginData): String? { + val existingFileId: String? = loginData.syncFileId ?: drive + .files() + .list() + .setQ("name='${loginData.fileName}' and trashed=false") + .execute() + .files + ?.getOrNull(0) + ?.id + + if (existingFileId != null && loginData.syncFileId == null) { + loginData.syncFileId = existingFileId + storeValue(K.LOGIN_DATA, loginData) + + return existingFileId + } + + val verifyId = drive.files().get(existingFileId) + return if (verifyId == null) { + return null + } else { + existingFileId + } + } + + override fun uploadSyncData() { + val ctx = AcraApplication.context ?: return + val loginData = getLatestLoginData() ?: return + ctx.createBackup(loginData) + } + + override fun shouldUpdate(): Boolean { + val ctx = AcraApplication.context ?: return false + + val newBackup = ctx.getBackup().toJson() + return lastBackupJson != newBackup + } + + private fun getDriveService(): Drive? { + val credential = getCredentialsFromStore() ?: return null + + return Drive.Builder( + GAPI.HTTP_TRANSPORT, + GAPI.JSON_FACTORY, + credential + ) + .setApplicationName("cloudstreamapp-drive-sync") + .build() + } + + ///////////////////////////////////////// + ///////////////////////////////////////// + // Internal + private fun getCredentialsFromStore(): Credential? { + val LOGIN_DATA = getLatestLoginData() + val TOKEN = getValue(K.TOKEN) + + val credential = if (LOGIN_DATA != null && TOKEN != null) { + GAPI.getCredentials(TOKEN, LOGIN_DATA) + } else { + return null + } + + if (credential.expirationTimeMilliseconds < Date().time) { + val success = credential.refreshToken() + + if (!success) { + logOut() + return null + } + } + + return credential + } + + ///////////////////////////////////////// + ///////////////////////////////////////// + // Google API integration helper + object GAPI { + private const val DATA_STORE_ID = "gdrive_tokens" + private val USED_SCOPES = listOf(DriveScopes.DRIVE_FILE) + val HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport() + val JSON_FACTORY = GsonFactory.getDefaultInstance() + + fun createAuthFlow(clientId: String, clientSecret: String): GoogleAuthorizationCodeFlow = + GoogleAuthorizationCodeFlow.Builder( + HTTP_TRANSPORT, + JSON_FACTORY, + clientId, + clientSecret, + USED_SCOPES + ) + .setCredentialDataStore(MemoryDataStoreFactory().getDataStore(DATA_STORE_ID)) + .setApprovalPrompt("force") + .setAccessType("offline") + .build() + + fun getCredentials( + tokenResponse: TokenResponse, + loginData: InAppOAuth2API.LoginData, + ): Credential = createAuthFlow( + loginData.clientId, + loginData.secret + ).loadCredential(loginData.clientId) ?: createAuthFlow( + loginData.clientId, + loginData.secret + ).createAndStoreCredential( + tokenResponse, + loginData.clientId + ) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 1ef3cb55..b6d33f81 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -2,38 +2,31 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.view.View -import android.view.View.* -import android.view.inputmethod.EditorInfo import android.widget.TextView import androidx.annotation.UiThread import androidx.appcompat.app.AlertDialog -import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceFragmentCompat import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser -import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.* import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.googleDriveApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi -import com.lagradost.cloudstream3.syncproviders.AuthAPI -import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI -import com.lagradost.cloudstream3.syncproviders.OAuth2API import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.ui.settings.helpers.settings.account.InAppAuthDialogBuilder +import com.lagradost.cloudstream3.ui.settings.helpers.settings.account.InAppOAuth2DialogBuilder import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.setImage import kotlinx.android.synthetic.main.account_managment.* import kotlinx.android.synthetic.main.account_switch.* -import kotlinx.android.synthetic.main.add_account_input.* class SettingsAccount : PreferenceFragmentCompat() { companion object { @@ -108,122 +101,9 @@ class SettingsAccount : PreferenceFragmentCompat() { fun addAccount(activity: FragmentActivity?, api: AccountManager) { try { when (api) { - is OAuth2API -> { - api.authenticate(activity) - } - is InAppAuthAPI -> { - val builder = - AlertDialog.Builder(activity ?: return, R.style.AlertDialogCustom) - .setView(R.layout.add_account_input) - val dialog = builder.show() - - val visibilityMap = mapOf( - dialog.login_email_input to api.requiresEmail, - dialog.login_password_input to api.requiresPassword, - dialog.login_server_input to api.requiresServer, - dialog.login_username_input to api.requiresUsername - ) - - if (isTvSettings()) { - visibilityMap.forEach { (input, isVisible) -> - input.isVisible = isVisible - - // Band-aid for weird FireTV behavior causing crashes because keyboard covers the screen - input.setOnEditorActionListener { textView, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_NEXT) { - val view = textView.focusSearch(FOCUS_DOWN) - return@setOnEditorActionListener view?.requestFocus( - FOCUS_DOWN - ) == true - } - return@setOnEditorActionListener true - } - } - } else { - visibilityMap.forEach { (input, isVisible) -> - input.isVisible = isVisible - } - } - - dialog.login_email_input?.isVisible = api.requiresEmail - dialog.login_password_input?.isVisible = api.requiresPassword - dialog.login_server_input?.isVisible = api.requiresServer - dialog.login_username_input?.isVisible = api.requiresUsername - dialog.create_account?.isGone = api.createAccountUrl.isNullOrBlank() - dialog.create_account?.setOnClickListener { - openBrowser( - api.createAccountUrl ?: return@setOnClickListener, - activity - ) - dialog.dismissSafe() - } - - val displayedItems = listOf( - dialog.login_username_input, - dialog.login_email_input, - dialog.login_server_input, - dialog.login_password_input - ).filter { it.isVisible } - - displayedItems.foldRight(displayedItems.firstOrNull()) { item, previous -> - item?.id?.let { previous?.nextFocusDownId = it } - previous?.id?.let { item?.nextFocusUpId = it } - item - } - - displayedItems.firstOrNull()?.let { - dialog.create_account?.nextFocusDownId = it.id - it.nextFocusUpId = dialog.create_account.id - } - dialog.apply_btt?.id?.let { - displayedItems.lastOrNull()?.nextFocusDownId = it - } - - dialog.text1?.text = api.name - - if (api.storesPasswordInPlainText) { - api.getLatestLoginData()?.let { data -> - dialog.login_email_input?.setText(data.email ?: "") - dialog.login_server_input?.setText(data.server ?: "") - dialog.login_username_input?.setText(data.username ?: "") - dialog.login_password_input?.setText(data.password ?: "") - } - } - - dialog.apply_btt?.setOnClickListener { - val loginData = InAppAuthAPI.LoginData( - username = if (api.requiresUsername) dialog.login_username_input?.text?.toString() else null, - password = if (api.requiresPassword) dialog.login_password_input?.text?.toString() else null, - email = if (api.requiresEmail) dialog.login_email_input?.text?.toString() else null, - server = if (api.requiresServer) dialog.login_server_input?.text?.toString() else null, - ) - ioSafe { - val isSuccessful = try { - api.login(loginData) - } catch (e: Exception) { - logError(e) - false - } - activity.runOnUiThread { - try { - showToast( - activity, - activity.getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail) - .format( - api.name - ) - ) - } catch (e: Exception) { - logError(e) // format might fail - } - } - } - dialog.dismissSafe(activity) - } - dialog.cancel_btt?.setOnClickListener { - dialog.dismissSafe(activity) - } - } + is InAppOAuth2API -> InAppOAuth2DialogBuilder(api, activity).open() + is OAuth2API -> api.authenticate(activity) + is InAppAuthAPI -> InAppAuthDialogBuilder(api, activity).open() else -> { throw NotImplementedError("You are trying to add an account that has an unknown login method") } @@ -249,6 +129,7 @@ class SettingsAccount : PreferenceFragmentCompat() { R.string.mal_key to malApi, R.string.anilist_key to aniListApi, R.string.opensubtitles_key to openSubtitlesApi, + R.string.gdrive_key to googleDriveApi ) for ((key, api) in syncApis) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt new file mode 100644 index 00000000..741e2817 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt @@ -0,0 +1,130 @@ +package com.lagradost.cloudstream3.ui.settings.helpers.settings.account + +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentActivity +import com.google.android.material.button.MaterialButton +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.ui.settings.SettingsFragment +import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe + +abstract class DialogBuilder( + private val api: AuthAPI, + private val activity: FragmentActivity?, + private val themeResId: Int, + private val layoutResId: Int, +) { + class CommonDialogItems( + private val dialog: AlertDialog, + private val title: TextView, + private val btnApply: MaterialButton, + private val btnCancel: MaterialButton, + private val btnAccCreate: MaterialButton?, + private val btnConfirmOauth: MaterialButton? + ) { + fun getTitle() = dialog.getCommonItem(title)!! + fun getBtnApply() = dialog.getCommonItem(btnApply)!! + fun getBtnCancel() = dialog.getCommonItem(btnCancel)!! + fun getBtnAccCreate() = dialog.getCommonItem(btnAccCreate) + fun getBtnConfirm() = dialog.getCommonItem(btnConfirmOauth) + + private fun AlertDialog.getCommonItem(view: T?): T? { + return findViewById(view?.id ?: return null) + } + } + + + abstract fun getCommonItems(dialog: AlertDialog): CommonDialogItems + abstract fun getVisibilityMap(dialog: AlertDialog): Map + abstract fun setupItems(dialog: AlertDialog) + + + open fun handleStoresPasswordInPlainText(dialog: AlertDialog) {} + open fun onDismiss(dialog: AlertDialog) { + dialog.dismissSafe(activity) + } + + open fun onLogin(dialog: AlertDialog) { + dialog.dismissSafe(activity) + } + + + fun open(): AlertDialog? { + if (activity == null) { + return null + } + + val dialogBuilder = AlertDialog.Builder(activity, themeResId).setView(layoutResId) + val dialog = dialogBuilder.show() + + setup(dialog) + handleStoresPasswordInPlainText(dialog) + + val commonItems = getCommonItems(dialog) + commonItems.getTitle().text = api.name + commonItems.getBtnApply().setOnClickListener { onLogin(dialog) } + commonItems.getBtnCancel().setOnClickListener { onDismiss(dialog) } + + return dialog + } + + + protected fun setup(dialog: AlertDialog) { + setItemVisibility(dialog) + setupItems(dialog) + linkItems(dialog) + } + + private fun setItemVisibility(dialog: AlertDialog) { + val visibilityMap = getVisibilityMap(dialog) + + if (SettingsFragment.isTvSettings()) { + visibilityMap.forEach { (input, isVisible) -> + input.isVisible = isVisible + + if (input !is TextView) { + return@forEach + } + + // Band-aid for weird FireTV behavior causing crashes because keyboard covers the screen + input.setOnEditorActionListener { textView, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_NEXT) { + val view = textView.focusSearch(View.FOCUS_DOWN) + return@setOnEditorActionListener view?.requestFocus( + View.FOCUS_DOWN + ) == true + } + return@setOnEditorActionListener true + } + } + } else { + visibilityMap.forEach { (input, isVisible) -> + input.isVisible = isVisible + } + } + } + + private fun linkItems(dialog: AlertDialog) = with(dialog) { + val displayedItems = getVisibilityMap(dialog).keys.filter { it.isVisible } + + displayedItems.foldRight(displayedItems.firstOrNull()) { item, previous -> + item?.id?.let { previous?.nextFocusDownId = it } + previous?.id?.let { item?.nextFocusUpId = it } + item + } + + displayedItems.firstOrNull()?.let { + val createAccount = getCommonItems(dialog).getBtnAccCreate() ?: return@let + createAccount.nextFocusDownId = it.id + it.nextFocusUpId = createAccount.id + } + + getCommonItems(dialog).getBtnApply().id.let { + displayedItems.lastOrNull()?.nextFocusDownId = it + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt new file mode 100644 index 00000000..47ec545e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt @@ -0,0 +1,100 @@ +package com.lagradost.cloudstream3.ui.settings.helpers.settings.account + +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentActivity +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import kotlinx.android.synthetic.main.add_account_input.* + +class InAppAuthDialogBuilder( + private val api: InAppAuthAPI, + private val activity: FragmentActivity?, +) : DialogBuilder( + api, + activity, + R.style.AlertDialogCustom, + R.layout.add_account_input, +) { + + override fun onLogin(dialog: AlertDialog): Unit = with(dialog) { + if (activity == null) throw IllegalStateException("Login should be called after validation") + + val loginData = InAppAuthAPI.LoginData( + username = if (api.requiresUsername) login_username_input?.text?.toString() else null, + password = if (api.requiresPassword) login_password_input?.text?.toString() else null, + email = if (api.requiresEmail) login_email_input?.text?.toString() else null, + server = if (api.requiresServer) login_server_input?.text?.toString() else null, + ) + + ioSafe { + val isSuccessful = try { + api.login(loginData) + } catch (e: Exception) { + logError(e) + false + } + activity.runOnUiThread { + try { + CommonActivity.showToast( + activity, + activity.getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail) + .format( + api.name + ) + ) + } catch (e: Exception) { + logError(e) // format might fail + } + } + } + + } + + override fun getCommonItems(dialog: AlertDialog) = with(dialog) { + CommonDialogItems(dialog, text1, apply_btt, cancel_btt, create_account,null) + } + + override fun getVisibilityMap(dialog: AlertDialog): Map = with(dialog) { + mapOf( + login_email_input to api.requiresEmail, + login_password_input to api.requiresPassword, + login_server_input to api.requiresServer, + login_username_input to api.requiresUsername + ) + } + + override fun setupItems(dialog: AlertDialog): Unit = with(dialog) { + login_email_input?.isVisible = api.requiresEmail + login_password_input?.isVisible = api.requiresPassword + login_server_input?.isVisible = api.requiresServer + login_username_input?.isVisible = api.requiresUsername + + create_account?.isGone = api.createAccountUrl.isNullOrBlank() + create_account?.setOnClickListener { + AcraApplication.openBrowser( + api.createAccountUrl ?: return@setOnClickListener, activity + ) + + dismissSafe() + } + } + + override fun handleStoresPasswordInPlainText(dialog: AlertDialog): Unit = with(dialog) { + if (!api.storesPasswordInPlainText) return + + api.getLatestLoginData()?.let { data -> + login_email_input?.setText(data.email ?: "") + login_server_input?.setText(data.server ?: "") + login_username_input?.setText(data.username ?: "") + login_password_input?.setText(data.password ?: "") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt new file mode 100644 index 00000000..1d93d682 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt @@ -0,0 +1,68 @@ +package com.lagradost.cloudstream3.ui.settings.helpers.settings.account + +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import kotlinx.android.synthetic.main.add_account_input_oauth.* + + +class InAppOAuth2DialogBuilder( + private val api: InAppOAuth2API, + private val activity: FragmentActivity?, +) : DialogBuilder(api, activity, R.style.AlertDialogCustom, R.layout.add_account_input_oauth) { + override fun getCommonItems(dialog: AlertDialog) = with(dialog) { + CommonDialogItems(dialog, text1, apply_btt, cancel_btt, null, null) + } + + override fun getVisibilityMap(dialog: AlertDialog): Map = with(dialog) { + mapOf( + login_file_name to api.requiresFilename, + login_client_id to api.requiresClientId, + login_client_secret to api.requiresSecret, + ) + } + + override fun setupItems(dialog: AlertDialog): Unit = with(dialog) { + login_file_name?.isVisible = api.requiresFilename + login_client_id?.isVisible = api.requiresClientId + login_client_secret?.isVisible = api.requiresSecret + } + + + override fun onLogin(dialog: AlertDialog): Unit = with(activity) { + if (this == null) throw IllegalStateException("Login should be called after validation") + + val clientId = dialog.login_client_id.text.toString().ifBlank { + getString(R.string.debug_gdrive_clientId) + } + val clientSecret = dialog.login_client_secret.text.toString().ifBlank { + getString(R.string.debug_gdrive_secret) + } + val syncFileName = dialog.login_file_name.text.toString().trim().ifBlank { + api.defaultFilenameValue + } + val redirectUrl = dialog.login_file_name.text.toString().trim().ifBlank { + api.defaultRedirectUrl + } + + ioSafe { + api.getAuthorizationToken( + this@with, + InAppOAuth2API.LoginData( + clientId = clientId, + secret = clientSecret, + fileNameInput = syncFileName, + redirectUrl = redirectUrl, + syncFileId = null + ) + ) + } + + dialog.dismissSafe() + } +} 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 2318fda6..002d617c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -18,6 +18,7 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.plugins.PLUGINS_KEY import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL +import com.lagradost.cloudstream3.syncproviders.InAppOAuth2APIManager import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_TOKEN_KEY import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_UNIXTIME_KEY @@ -60,6 +61,7 @@ object BackupUtils { MAL_CACHED_LIST, MAL_UNIXTIME_KEY, MAL_USER_KEY, + InAppOAuth2APIManager.K.TOKEN.value, // The plugins themselves are not backed up PLUGINS_KEY, @@ -71,7 +73,7 @@ object BackupUtils { /** false if blacklisted key */ private fun String.isTransferable(): Boolean { - return !nonTransferableKeys.contains(this) + return !nonTransferableKeys.any { this.contains(it) } } private var restoreFileSelector: ActivityResultLauncher>? = null diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index e1cedd39..06267048 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.module.kotlin.KotlinModule import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AccountManager const val DOWNLOAD_HEADER_CACHE = "download_header_cache" @@ -20,7 +21,9 @@ const val PREFERENCES_NAME = "rebuild_preference" object DataStore { val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule()) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(DeserializationFeature.USE_LONG_FOR_INTS, true) + .build() private fun getPreferences(context: Context): SharedPreferences { return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) @@ -80,6 +83,8 @@ object DataStore { val editor: SharedPreferences.Editor = prefs.edit() editor.remove(path) editor.apply() + + AccountManager.BackupApis.forEach { it.addToQueue() } } } catch (e: Exception) { logError(e) @@ -99,6 +104,8 @@ object DataStore { val editor: SharedPreferences.Editor = getSharedPrefs().edit() editor.putString(path, mapper.writeValueAsString(value)) editor.apply() + + AccountManager.BackupApis.forEach { it.addToQueue() } } catch (e: Exception) { logError(e) } diff --git a/app/src/main/res/layout/add_account_input_oauth.xml b/app/src/main/res/layout/add_account_input_oauth.xml new file mode 100644 index 00000000..efc929f2 --- /dev/null +++ b/app/src/main/res/layout/add_account_input_oauth.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ac76e243..2c37fcce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -446,9 +446,12 @@ Poster title location Put the title under the poster + Plugins + Remote Sync anilist_key mal_key opensubtitles_key + gdrive_key nginx_key password123 MyCoolUsername @@ -457,6 +460,9 @@ MyCoolSite example.com Language code (en) + cloudstreamapp-sync-file + OAuth Client ID + OAuth Client Secret From 100d3dac7cf9268d2758883bd64626714f5af393 Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Mon, 3 Apr 2023 22:05:27 +0200 Subject: [PATCH 02/37] feat: add remote sync capability - fix build.gradle.kts --- app/build.gradle.kts | 31 ++++++++++++------- .../syncproviders/providers/GoogleDriveApi.kt | 2 +- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 502a3ec0..fe064356 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,7 +27,13 @@ fun String.execute() = ByteArrayOutputStream().use { baot -> } val localProperties = Properties() -localProperties.load(FileInputStream(rootProject.file("local.properties"))) +val localPropertiesEnabled = try { + localProperties.load(FileInputStream(rootProject.file("local.properties"))) + true +} catch(_: Exception) { + false +} + android { testOptions { @@ -88,16 +94,19 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) - resValue( - "string", - "debug_gdrive_secret", - localProperties.getProperty("debug.gdrive.secret") ?: "" - ) - resValue( - "string", - "debug_gdrive_clientId", - localProperties.getProperty("debug.gdrive.clientId") ?: "" - ) + + if (localPropertiesEnabled) { + resValue( + "string", + "debug_gdrive_secret", + localProperties.getProperty("debug.gdrive.secret") ?: "" + ) + resValue( + "string", + "debug_gdrive_clientId", + localProperties.getProperty("debug.gdrive.clientId") ?: "" + ) + } } } flavorDimensions.add("state") diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index 10ab64b4..9d3858dd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -38,7 +38,7 @@ import java.io.InputStream import java.util.* -// TODO: improvements and ideas +// improvements and ideas // - add option to use proper oauth through google services one tap - would need google console project on behalf of cloudstream // - encrypt data on drive // - choose what should be synced From d50fbe566cec3190c39aa319f5133e5ff48703d0 Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Mon, 3 Apr 2023 22:15:57 +0200 Subject: [PATCH 03/37] feat: add remote sync capability - fix build.gradle.kts, remove unused strings --- app/build.gradle.kts | 30 ++++++++++++++--------------- app/src/main/res/values/strings.xml | 4 +--- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fe064356..d49b2878 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,11 +27,11 @@ fun String.execute() = ByteArrayOutputStream().use { baot -> } val localProperties = Properties() -val localPropertiesEnabled = try { +try { localProperties.load(FileInputStream(rootProject.file("local.properties"))) - true -} catch(_: Exception) { - false +} catch (_: Exception) { + localProperties.setProperty("debug.gdrive.clientId", "") + localProperties.setProperty("debug.gdrive.secret", "") } @@ -95,18 +95,16 @@ android { "proguard-rules.pro" ) - if (localPropertiesEnabled) { - resValue( - "string", - "debug_gdrive_secret", - localProperties.getProperty("debug.gdrive.secret") ?: "" - ) - resValue( - "string", - "debug_gdrive_clientId", - localProperties.getProperty("debug.gdrive.clientId") ?: "" - ) - } + resValue( + "string", + "debug_gdrive_secret", + localProperties.getProperty("debug.gdrive.secret") ?: "" + ) + resValue( + "string", + "debug_gdrive_clientId", + localProperties.getProperty("debug.gdrive.clientId") ?: "" + ) } } flavorDimensions.add("state") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2c37fcce..ee51167e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -663,9 +663,7 @@ Subscribed to %s Unsubscribed from %s Episode %d released! - OAuth Confirmation Code - Confirm Sync file name (optional) Oauth redirect url (optional) - https://chiff.github.io/cloudstream-sync/google-drive + https://chiff.github.io/cloudstream-sync/google-drive \ No newline at end of file From 5ec443916bc9f7bf3e2e5d63f1c1f9992f1a058e Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Wed, 5 Apr 2023 00:47:22 +0200 Subject: [PATCH 04/37] feat: add remote sync capability - update scheduling logic --- .../cloudstream3/syncproviders/BackupAPI.kt | 85 +++++++++++++++++-- .../syncproviders/providers/GoogleDriveApi.kt | 60 +++++++------ .../cloudstream3/ui/player/GeneratorPlayer.kt | 3 +- .../ui/settings/SettingsGeneral.kt | 13 ++- .../ui/settings/SettingsPlayer.kt | 3 +- .../ui/settings/SettingsProviders.kt | 3 +- .../cloudstream3/ui/settings/SettingsUI.kt | 3 +- .../ui/settings/SettingsUpdates.kt | 3 +- .../ui/setup/SetupFragmentLanguage.kt | 3 +- .../ui/setup/SetupFragmentLayout.kt | 3 +- .../ui/setup/SetupFragmentMedia.kt | 3 +- .../ui/setup/SetupFragmentProviderLanguage.kt | 3 +- .../ui/subtitles/SubtitlesFragment.kt | 2 + .../lagradost/cloudstream3/utils/DataStore.kt | 11 +-- .../cloudstream3/utils/InAppUpdater.kt | 5 +- 15 files changed, 148 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt index b184117b..6934273e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -1,36 +1,103 @@ package com.lagradost.cloudstream3.syncproviders import android.content.Context +import android.content.SharedPreferences +import android.os.Handler +import android.os.Looper +import android.util.Log import com.lagradost.cloudstream3.mvvm.launchSafe import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.seconds interface BackupAPI { - private companion object { - val DEBOUNCE_TIME_MS = 15.seconds + companion object { + val UPLOAD_THROTTLE = 10.seconds + val DOWNLOAD_THROTTLE = 60.seconds + + fun createBackupScheduler() = Scheduler>( + UPLOAD_THROTTLE.inWholeMilliseconds + ) { input -> + if (input == null) { + throw IllegalStateException() + } + + AccountManager.BackupApis.forEach { it.addToQueue(input.first, input.second) } + } + + fun SharedPreferences.attachListener(isSettings: Boolean = true): Pair>> { + val scheduler = createBackupScheduler() + registerOnSharedPreferenceChangeListener { _, key -> + scheduler.work(Pair(key, isSettings)) + } + + return Pair( + this, + scheduler + ) + } } fun downloadSyncData() fun uploadSyncData() - fun shouldUpdate(): Boolean + fun shouldUpdate(changedKey: String, isSettings: Boolean): Boolean fun Context.mergeBackup(incomingData: String) fun Context.createBackup(loginData: LOGIN_DATA) var uploadJob: Job? - fun addToQueue() { - if (!shouldUpdate()) { + fun addToQueue(changedKey: String, isSettings: Boolean) { + if (!shouldUpdate(changedKey, isSettings)) { + Log.d("SYNC_API", "upload not required, data is same") return } - uploadJob?.cancel() + if (uploadJob != null && uploadJob!!.isActive) { + Log.d("SYNC_API", "upload is canceled, scheduling new") + uploadJob?.cancel() + } + + // we should ensure job will before app is closed uploadJob = CoroutineScope(Dispatchers.IO).launchSafe { - delay(DEBOUNCE_TIME_MS) + Log.d("SYNC_API", "upload is running now") uploadSyncData() } } -} \ No newline at end of file + + class Scheduler( + private val throttleTimeMs: Long, + private val onWork: (INPUT?) -> Unit + ) { + private companion object { + var SCHEDULER_ID = 1 + } + + private val id = SCHEDULER_ID++ + private val handler = Handler(Looper.getMainLooper()) + private var runnable: Runnable? = null + + fun work(input: INPUT? = null) { + Log.d("SYNC_API", "[$id] wants to schedule") + throttle(input) + } + + fun stop() { + runnable?.let { + handler.removeCallbacks(it) + runnable = null + } + } + + private fun throttle(input: INPUT?) { + stop() + + runnable = Runnable { + Log.d("SYNC_API", "[$id] schedule success") + onWork(input) + } + handler.postDelayed(runnable!!, throttleTimeMs) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index 9d3858dd..d04d0ce0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.syncproviders.providers import android.content.Context import android.net.Uri +import android.util.Log import androidx.browser.customtabs.CustomTabsIntent import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.module.kotlin.readValue @@ -19,7 +20,6 @@ import com.google.api.services.drive.model.File import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.BackupAPI import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API @@ -29,11 +29,9 @@ import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.getBackup import com.lagradost.cloudstream3.utils.BackupUtils.restore import com.lagradost.cloudstream3.utils.DataStore +import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.removeKey -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import java.io.InputStream import java.util.* @@ -68,8 +66,6 @@ class GoogleDriveApi(index: Int) : var tempAuthFlow: AuthorizationCodeFlow? = null var lastBackupJson: String? = null - var continuousDownloadJob: Job? = null - ///////////////////////////////////////// ///////////////////////////////////////// // OAuth2API implementation @@ -104,7 +100,7 @@ class GoogleDriveApi(index: Int) : ) storeValue(K.TOKEN, googleTokenResponse) - startContinuousDownload() + runDownloader() tempAuthFlow = null return true @@ -118,22 +114,7 @@ class GoogleDriveApi(index: Int) : return } - startContinuousDownload() - } - - private fun startContinuousDownload() { - continuousDownloadJob?.cancel() - continuousDownloadJob = CoroutineScope(Dispatchers.IO).launchSafe { - if (uploadJob?.isActive == true) { - uploadJob!!.invokeOnCompletion { - startContinuousDownload() - } - } else { - downloadSyncData() - delay(1000 * 60) - startContinuousDownload() - } - } + runDownloader() } override fun loginInfo(): AuthAPI.LoginInfo? { @@ -192,13 +173,11 @@ class GoogleDriveApi(index: Int) : removeKey(it) } - restore( newData, restoreSettings = true, restoreDataStore = true ) - } // 🤮 @@ -227,6 +206,7 @@ class GoogleDriveApi(index: Int) : val drive = getDriveService()!! val fileName = loginData.fileName + val syncFileId = loginData.syncFileId val ioFile = java.io.File(AcraApplication.context?.cacheDir, fileName) lastBackupJson = getBackup().toJson() ioFile.writeText(lastBackupJson!!) @@ -250,7 +230,10 @@ class GoogleDriveApi(index: Int) : loginData.syncFileId = file.id } - storeValue(K.LOGIN_DATA, loginData) + // in case we had to create new file + if (syncFileId != loginData.syncFileId) { + storeValue(K.LOGIN_DATA, loginData) + } } override fun downloadSyncData() { @@ -273,11 +256,13 @@ class GoogleDriveApi(index: Int) : try { val inputStream: InputStream = existingFile.executeMediaAsInputStream() val content: String = inputStream.bufferedReader().use { it.readText() } + Log.d("SYNC_API", "downloadSyncData merging") ctx.mergeBackup(content) return } catch (_: Exception) { } } else { + Log.d("SYNC_API", "downloadSyncData file not exists") uploadSyncData() } } @@ -310,12 +295,14 @@ class GoogleDriveApi(index: Int) : override fun uploadSyncData() { val ctx = AcraApplication.context ?: return val loginData = getLatestLoginData() ?: return + Log.d("SYNC_API", "uploadSyncData createBackup") ctx.createBackup(loginData) } - override fun shouldUpdate(): Boolean { + override fun shouldUpdate(changedKey: String, isSettings: Boolean): Boolean { val ctx = AcraApplication.context ?: return false + // would be smarter to check properties, but its called once in UPLOAD_THROTTLE seconds val newBackup = ctx.getBackup().toJson() return lastBackupJson != newBackup } @@ -335,6 +322,25 @@ class GoogleDriveApi(index: Int) : ///////////////////////////////////////// ///////////////////////////////////////// // Internal + private val continuousDownloader = BackupAPI.Scheduler( + BackupAPI.DOWNLOAD_THROTTLE.inWholeMilliseconds + ) { + if (uploadJob?.isActive == true) { + uploadJob!!.invokeOnCompletion { + Log.d("SYNC_API", "upload is running, reschedule download") + runDownloader() + } + } else { + Log.d("SYNC_API", "downloadSyncData will run") + downloadSyncData() + runDownloader() + } + } + + private fun runDownloader() { + continuousDownloader.work() + } + private fun getCredentialsFromStore(): Credential? { val LOGIN_DATA = getLatestLoginData() val TOKEN = getValue(K.TOKEN) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 46f2bca9..894dfdbf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -28,6 +28,7 @@ import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.mvvm.* import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType @@ -664,7 +665,7 @@ class GeneratorPlayer : FullScreenPlayer() { } sourceDialog.subtitles_click_settings?.setOnClickListener { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx).attachListener().first val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) val prefValues = ctx.resources.getStringArray(R.array.subtitles_encoding_values) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt index 4aa859aa..05c956f0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -25,6 +25,7 @@ import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.network.initClient +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener import com.lagradost.cloudstream3.ui.EasterEggMonke import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom @@ -137,20 +138,26 @@ class SettingsGeneral : PreferenceFragmentCompat() { // Stores the real URI using download_path_key // Important that the URI is stored instead of filepath due to permissions. PreferenceManager.getDefaultSharedPreferences(context) - .edit().putString(getString(R.string.download_path_key), uri.toString()).apply() + .attachListener().first + .edit() + .putString(getString(R.string.download_path_key), uri.toString()) + .apply() // From URI -> File path // File path here is purely for cosmetic purposes in settings (file.filePath ?: uri.toString()).let { PreferenceManager.getDefaultSharedPreferences(context) - .edit().putString(getString(R.string.download_path_pref), it).apply() + .attachListener().first + .edit() + .putString(getString(R.string.download_path_pref), it) + .apply() } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settins_general, rootKey) - val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()).attachListener().first fun getCurrent(): MutableList { return getKey>(USER_PROVIDER_API)?.toMutableList() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt index e10a5a1a..e065ef12 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt @@ -7,6 +7,7 @@ import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getFolderSize import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom @@ -27,7 +28,7 @@ class SettingsPlayer : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_player, rootKey) - val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()).attachListener().first getPref(R.string.video_buffer_length_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.video_buffer_length_names) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt index 42a864a6..e791365b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt @@ -10,6 +10,7 @@ import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom @@ -30,7 +31,7 @@ class SettingsProviders : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_providers, rootKey) - val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()).attachListener().first getPref(R.string.display_sub_key)?.setOnPreferenceClickListener { activity?.getApiDubstatusSettings()?.let { current -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt index e2fd24ca..b19c1ee5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt @@ -8,6 +8,7 @@ import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom @@ -28,7 +29,7 @@ class SettingsUI : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settins_ui, rootKey) - val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()).attachListener().first getPref(R.string.poster_ui_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.poster_ui_options) 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 f9ac3fee..3590e28e 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 @@ -14,6 +14,7 @@ import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar @@ -126,7 +127,7 @@ class SettingsUpdates : PreferenceFragmentCompat() { } getPref(R.string.apk_installer_key)?.setOnPreferenceClickListener { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context).attachListener().first val prefNames = resources.getStringArray(R.array.apk_installer_pref) val prefValues = resources.getIntArray(R.array.apk_installer_values) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt index 80db59ee..63de8c96 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt @@ -15,6 +15,7 @@ import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener import com.lagradost.cloudstream3.ui.settings.appLanguages import com.lagradost.cloudstream3.ui.settings.getCurrentLocale import com.lagradost.cloudstream3.utils.SubtitleHelper @@ -42,7 +43,7 @@ class SetupFragmentLanguage : Fragment() { normalSafeApiCall { with(context) { if (this == null) return@normalSafeApiCall - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().first val arrayAdapter = ArrayAdapter(this, R.layout.sort_bottom_single_choice) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt index 50fb37d6..db3d2e49 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt @@ -10,6 +10,7 @@ import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import kotlinx.android.synthetic.main.fragment_setup_layout.* import kotlinx.android.synthetic.main.fragment_setup_media.listview1 @@ -33,7 +34,7 @@ class SetupFragmentLayout : Fragment() { with(context) { if (this == null) return - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().first val prefNames = resources.getStringArray(R.array.app_layout) val prefValues = resources.getIntArray(R.array.app_layout_values) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt index 257ce5c1..5e8a42ae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt @@ -12,6 +12,7 @@ import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener import com.lagradost.cloudstream3.utils.DataStore.removeKey import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar @@ -32,7 +33,7 @@ class SetupFragmentMedia : Fragment() { with(context) { if (this == null) return - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().first val arrayAdapter = ArrayAdapter(this, R.layout.sort_bottom_single_choice) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt index 51abee90..74b1d77e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt @@ -14,6 +14,7 @@ import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import kotlinx.android.synthetic.main.fragment_setup_media.* @@ -33,7 +34,7 @@ class SetupFragmentProviderLanguage : Fragment() { with(context) { if (this == null) return - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().first val arrayAdapter = ArrayAdapter(this, R.layout.sort_bottom_single_choice) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt index ff0e0e82..1799a412 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt @@ -27,6 +27,7 @@ import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event @@ -447,6 +448,7 @@ class SubtitlesFragment : Fragment() { subtitles_filter_sub_lang?.setOnCheckedChangeListener { _, b -> context?.let { ctx -> PreferenceManager.getDefaultSharedPreferences(ctx) + .attachListener().first .edit() .putBoolean(getString(R.string.filter_sub_lang_key), b) .apply() diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index 06267048..8c52e221 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -7,7 +7,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.module.kotlin.KotlinModule import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.BackupAPI const val DOWNLOAD_HEADER_CACHE = "download_header_cache" @@ -25,6 +25,8 @@ object DataStore { .configure(DeserializationFeature.USE_LONG_FOR_INTS, true) .build() + private val backupScheduler = BackupAPI.createBackupScheduler() + private fun getPreferences(context: Context): SharedPreferences { return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) } @@ -83,8 +85,7 @@ object DataStore { val editor: SharedPreferences.Editor = prefs.edit() editor.remove(path) editor.apply() - - AccountManager.BackupApis.forEach { it.addToQueue() } + backupScheduler.work(Pair(path, false)) } } catch (e: Exception) { logError(e) @@ -104,8 +105,7 @@ object DataStore { val editor: SharedPreferences.Editor = getSharedPrefs().edit() editor.putString(path, mapper.writeValueAsString(value)) editor.apply() - - AccountManager.BackupApis.forEach { it.addToQueue() } + backupScheduler.work(Pair(path, false)) } catch (e: Exception) { logError(e) } @@ -115,6 +115,7 @@ object DataStore { setKey(getFolderName(folder, path), value) } + inline fun String.toKotlinObject(): T { return mapper.readValue(this, T::class.java) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index 8b516e8c..09cf4138 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -23,6 +23,7 @@ import okio.buffer import okio.sink import java.io.File import android.text.TextUtils +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus import java.io.BufferedReader import java.io.IOException @@ -73,7 +74,7 @@ class InAppUpdater { private suspend fun Activity.getAppUpdate(): Update { return try { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().first if (settingsManager.getBoolean( getString(R.string.prerelease_update_key), resources.getBoolean(R.bool.is_prerelease) @@ -254,7 +255,7 @@ class InAppUpdater { * @param checkAutoUpdate if the update check was launched automatically **/ suspend fun Activity.runAutoUpdate(checkAutoUpdate: Boolean = true): Boolean { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().first if (!checkAutoUpdate || settingsManager.getBoolean( getString(R.string.auto_update_key), From 92578e54dd6881c6cf74c16813eccc57b15c4b5a Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Wed, 5 Apr 2023 01:19:32 +0200 Subject: [PATCH 05/37] feat: add remote sync capability - improve downloading --- .../cloudstream3/syncproviders/BackupAPI.kt | 6 ++++ .../syncproviders/providers/GoogleDriveApi.kt | 36 +++++++++++++------ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt index 6934273e..54ec832b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -83,6 +83,12 @@ interface BackupAPI { throttle(input) } + fun workNow(input: INPUT? = null) { + Log.d("SYNC_API", "[$id] runs immediate") + stop() + onWork(input) + } + fun stop() { runnable?.let { handler.removeCallbacks(it) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index d04d0ce0..7ede7aae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -28,8 +28,8 @@ import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.getBackup import com.lagradost.cloudstream3.utils.BackupUtils.restore +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.removeKey import kotlinx.coroutines.Job import java.io.InputStream @@ -100,7 +100,7 @@ class GoogleDriveApi(index: Int) : ) storeValue(K.TOKEN, googleTokenResponse) - runDownloader() + runDownloader(true) tempAuthFlow = null return true @@ -114,7 +114,9 @@ class GoogleDriveApi(index: Int) : return } - runDownloader() + ioSafe { + runDownloader(true) + } } override fun loginInfo(): AuthAPI.LoginInfo? { @@ -203,7 +205,7 @@ class GoogleDriveApi(index: Int) : old.orEmpty().keys.subtract(new.orEmpty().keys).toTypedArray() override fun Context.createBackup(loginData: InAppOAuth2API.LoginData) { - val drive = getDriveService()!! + val drive = getDriveService() ?: return val fileName = loginData.fileName val syncFileId = loginData.syncFileId @@ -219,7 +221,10 @@ class GoogleDriveApi(index: Int) : val fileId = getOrCreateSyncFileId(drive, loginData) if (fileId != null) { try { - val file = drive.files().update(fileId, fileMetadata, fileContent).execute() + val file = drive.files() + .update(fileId, fileMetadata, fileContent) + .setKeepRevisionForever(false) + .execute() loginData.syncFileId = file.id } catch (_: Exception) { val file = drive.files().create(fileMetadata, fileContent).execute() @@ -277,11 +282,15 @@ class GoogleDriveApi(index: Int) : ?.getOrNull(0) ?.id - if (existingFileId != null && loginData.syncFileId == null) { - loginData.syncFileId = existingFileId - storeValue(K.LOGIN_DATA, loginData) + if (loginData.syncFileId == null) { + if (existingFileId != null) { + loginData.syncFileId = existingFileId + storeValue(K.LOGIN_DATA, loginData) - return existingFileId + return existingFileId + } + + return null } val verifyId = drive.files().get(existingFileId) @@ -337,8 +346,13 @@ class GoogleDriveApi(index: Int) : } } - private fun runDownloader() { - continuousDownloader.work() + private fun runDownloader(runNow: Boolean = false) { + if (runNow) { + continuousDownloader.workNow() + } else { + continuousDownloader.work() + + } } private fun getCredentialsFromStore(): Credential? { From b77aecff71a8bf72cf8d37a21f9ab2e8b9d68bd3 Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Wed, 5 Apr 2023 01:30:40 +0200 Subject: [PATCH 06/37] feat: add remote sync capability - update notes --- .../syncproviders/providers/GoogleDriveApi.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index 7ede7aae..6d6e4711 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -38,9 +38,12 @@ import java.util.* // improvements and ideas // - add option to use proper oauth through google services one tap - would need google console project on behalf of cloudstream -// - encrypt data on drive +// - encrypt data on drive - meh // - choose what should be synced -// - having a button to run sync would be nice +// - having a button to run sync would be nice - its really just for user to feel "safe" +// - having two or more devices in use at same time results in racing conditions +// - restoring backup should update view models - maybe just first fragment is enough +// - move "https://chiff.github.io/cloudstream-sync/google-drive" to cs3 repo class GoogleDriveApi(index: Int) : InAppOAuth2APIManager(index), BackupAPI { @@ -58,8 +61,7 @@ class GoogleDriveApi(index: Int) : override val requiresSecret = true override val requiresClientId = true override val defaultFilenameValue = "cloudstreamapp-sync-file" - override val defaultRedirectUrl = - "https://chiff.github.io/cloudstream-sync/google-drive" // TODO: we should move this one to cs3 repo + override val defaultRedirectUrl = "https://chiff.github.io/cloudstream-sync/google-drive" override var uploadJob: Job? = null From f4dfd2f5b99a8ae206a3700d533b3b571fae8f59 Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Sat, 22 Apr 2023 22:01:48 +0200 Subject: [PATCH 07/37] feat: add remote sync capability - refactor and improve syncing when multiple devices are online --- .gitignore | 1 + app/build.gradle.kts | 1 + .../cloudstream3/syncproviders/BackupAPI.kt | 183 ++++++++++++++++-- .../syncproviders/providers/GoogleDriveApi.kt | 110 ++++------- .../cloudstream3/ui/player/GeneratorPlayer.kt | 8 +- .../ui/settings/SettingsGeneral.kt | 10 +- .../ui/settings/SettingsPlayer.kt | 6 +- .../ui/settings/SettingsProviders.kt | 15 +- .../cloudstream3/ui/settings/SettingsUI.kt | 6 +- .../ui/settings/SettingsUpdates.kt | 12 +- .../ui/setup/SetupFragmentLanguage.kt | 10 +- .../ui/setup/SetupFragmentLayout.kt | 9 +- .../ui/setup/SetupFragmentMedia.kt | 13 +- .../ui/setup/SetupFragmentProviderLanguage.kt | 11 +- .../ui/subtitles/SubtitlesFragment.kt | 5 +- .../cloudstream3/utils/BackupUtils.kt | 110 ++++++++--- .../lagradost/cloudstream3/utils/DataStore.kt | 40 +++- .../cloudstream3/utils/InAppUpdater.kt | 19 +- 18 files changed, 409 insertions(+), 160 deletions(-) diff --git a/.gitignore b/.gitignore index 2ac6c969..94109b73 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ .externalNativeBuild .cxx local.properties +/.idea \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b1a2e3ed..2d6f53d0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -251,6 +251,7 @@ dependencies { // color pallette for images -> colors implementation("androidx.palette:palette-ktx:1.0.0") + implementation("org.skyscreamer:jsonassert:1.2.3") implementation("androidx.browser:browser:1.4.0") implementation("com.google.api-client:google-api-client:2.0.0") { exclude( diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt index 54ec832b..0f65695c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -5,66 +5,211 @@ import android.content.SharedPreferences import android.os.Handler import android.os.Looper import android.util.Log +import com.fasterxml.jackson.module.kotlin.readValue import com.lagradost.cloudstream3.mvvm.launchSafe +import com.lagradost.cloudstream3.utils.BackupUtils +import com.lagradost.cloudstream3.utils.BackupUtils.getBackup +import com.lagradost.cloudstream3.utils.BackupUtils.restore +import com.lagradost.cloudstream3.utils.DataStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import org.skyscreamer.jsonassert.JSONCompare +import org.skyscreamer.jsonassert.JSONCompareMode +import org.skyscreamer.jsonassert.JSONCompareResult +import kotlin.system.measureTimeMillis import kotlin.time.Duration.Companion.seconds interface BackupAPI { + data class JSONComparison( + val failed: Boolean, + val result: JSONCompareResult? + ) + + data class PreferencesSchedulerData( + val storeKey: String, + val isSettings: Boolean + ) + + data class SharedPreferencesWithListener( + val self: SharedPreferences, + val scheduler: Scheduler + ) + companion object { + const val LOG_KEY = "BACKUP" + const val SYNC_HISTORY_PREFIX = "_hs/" + + // Can be called in high frequency (for now) because current implementation uses google + // cloud project per user so there is no way to hit quota. Later we should implement + // some kind of adaptive throttling which will increase decrease throttle time based + // on factors like: live devices, quota limits, etc val UPLOAD_THROTTLE = 10.seconds val DOWNLOAD_THROTTLE = 60.seconds - fun createBackupScheduler() = Scheduler>( + // add to queue may be called frequently + private val ioScope = CoroutineScope(Dispatchers.IO) + + fun createBackupScheduler() = Scheduler( UPLOAD_THROTTLE.inWholeMilliseconds ) { input -> if (input == null) { throw IllegalStateException() } - AccountManager.BackupApis.forEach { it.addToQueue(input.first, input.second) } + AccountManager.BackupApis.forEach { it.addToQueue(input.storeKey, input.isSettings) } } - fun SharedPreferences.attachListener(isSettings: Boolean = true): Pair>> { + // Common usage is `val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().self` + // which means it is mostly used for settings preferences, therefore we use `isSettings: Boolean = true`, be careful + // to turn it of if you need to directly access `context.getSharedPreferences` (without using DataStore) + + fun SharedPreferences.attachBackupListener( + isSettings: Boolean = true, + syncPrefs: SharedPreferences? = null + ): SharedPreferencesWithListener { val scheduler = createBackupScheduler() - registerOnSharedPreferenceChangeListener { _, key -> - scheduler.work(Pair(key, isSettings)) + registerOnSharedPreferenceChangeListener { _, storeKey -> + syncPrefs?.logHistoryChanged(storeKey, BackupUtils.RestoreSource.SETTINGS) + scheduler.work(PreferencesSchedulerData(storeKey, isSettings)) } - return Pair( - this, - scheduler - ) + return SharedPreferencesWithListener(this, scheduler) + } + + fun SharedPreferences.attachBackupListener(syncPrefs: SharedPreferences?): SharedPreferencesWithListener { + return attachBackupListener(true, syncPrefs) + } + + fun SharedPreferences.logHistoryChanged(path: String, source: BackupUtils.RestoreSource) { + edit().putLong("$SYNC_HISTORY_PREFIX${source.prefix}$path", System.currentTimeMillis()) + .apply() } } + /** + * Should download data from API and call Context.mergeBackup(incomingData: String). If data + * does not exist on the api uploadSyncData() is recommended to call + * @see Context.mergeBackup + * @see uploadSyncData + */ fun downloadSyncData() - fun uploadSyncData() - fun shouldUpdate(changedKey: String, isSettings: Boolean): Boolean - fun Context.mergeBackup(incomingData: String) + /** + * Should upload data to API and call Context.createBackup(loginData: LOGIN_DATA) + * @see Context.createBackup(loginData: LOGIN_DATA) + */ + fun uploadSyncData() + + fun Context.createBackup(loginData: LOGIN_DATA) + fun Context.mergeBackup(incomingData: String) { + val currentData = getBackup() + val newData = DataStore.mapper.readValue(incomingData) + + val keysToUpdate = getKeysToUpdate(currentData, newData) + if (keysToUpdate.isEmpty()) { + return + } + + restore( + newData, + keysToUpdate, + restoreSettings = true, + restoreDataStore = true, + restoreSyncData = true + ) + } var uploadJob: Job? + fun shouldUpdate(changedKey: String, isSettings: Boolean): Boolean fun addToQueue(changedKey: String, isSettings: Boolean) { if (!shouldUpdate(changedKey, isSettings)) { - Log.d("SYNC_API", "upload not required, data is same") + Log.d(LOG_KEY, "upload not required, data is same") return } if (uploadJob != null && uploadJob!!.isActive) { - Log.d("SYNC_API", "upload is canceled, scheduling new") + Log.d(LOG_KEY, "upload is canceled, scheduling new") uploadJob?.cancel() } // we should ensure job will before app is closed - uploadJob = CoroutineScope(Dispatchers.IO).launchSafe { - Log.d("SYNC_API", "upload is running now") + uploadJob = ioScope.launchSafe { + Log.d(LOG_KEY, "upload is running now") uploadSyncData() } } + fun compareJson(old: String, new: String): JSONComparison { + var result: JSONCompareResult? + + val executionTime = measureTimeMillis { + result = try { + JSONCompare.compareJSON(old, new, JSONCompareMode.LENIENT) + } catch (e: Exception) { + null + } + } + + val failed = result?.failed() ?: true + Log.d(LOG_KEY, "JSON comparison took $executionTime ms, compareFailed=$failed") + + return JSONComparison(failed, result) + } + + fun getKeysToUpdate( + currentData: BackupUtils.BackupFile, + newData: BackupUtils.BackupFile + ): List { + val currentSync = currentData.syncMeta._Long.orEmpty().filter { + it.key.startsWith(SYNC_HISTORY_PREFIX) + } + + val newSync = newData.syncMeta._Long.orEmpty().filter { + it.key.startsWith(SYNC_HISTORY_PREFIX) + } + + val changedKeys = newSync.filter { + val localTimestamp = if (currentSync[it.key] != null) { + currentSync[it.key]!! + } else { + 0L + } + + it.value > localTimestamp + }.keys + val onlyLocalKeys = currentSync.keys.filter { !newSync.containsKey(it) } + val missingKeys = getMissingKeys(currentData, newData) - changedKeys + + return mutableListOf( + *missingKeys.toTypedArray(), + *onlyLocalKeys.toTypedArray(), + *changedKeys.toTypedArray() + ) + } + + // 🤮 + private fun getMissingKeys( + old: BackupUtils.BackupFile, + new: BackupUtils.BackupFile + ): List = mutableListOf( + *getMissing(old.settings._Bool, new.settings._Bool), + *getMissing(old.settings._Long, new.settings._Long), + *getMissing(old.settings._Float, new.settings._Float), + *getMissing(old.settings._Int, new.settings._Int), + *getMissing(old.settings._String, new.settings._String), + *getMissing(old.settings._StringSet, new.settings._StringSet), + *getMissing(old.datastore._Bool, new.datastore._Bool), + *getMissing(old.datastore._Long, new.datastore._Long), + *getMissing(old.datastore._Float, new.datastore._Float), + *getMissing(old.datastore._Int, new.datastore._Int), + *getMissing(old.datastore._String, new.datastore._String), + *getMissing(old.datastore._StringSet, new.datastore._StringSet), + ) + + private fun getMissing(old: Map?, new: Map?): Array = + new.orEmpty().keys.subtract(old.orEmpty().keys).toTypedArray() class Scheduler( private val throttleTimeMs: Long, @@ -79,12 +224,12 @@ interface BackupAPI { private var runnable: Runnable? = null fun work(input: INPUT? = null) { - Log.d("SYNC_API", "[$id] wants to schedule") + Log.d(LOG_KEY, "[$id] wants to schedule [${input}]") throttle(input) } fun workNow(input: INPUT? = null) { - Log.d("SYNC_API", "[$id] runs immediate") + Log.d(LOG_KEY, "[$id] runs immediate [${input}]") stop() onWork(input) } @@ -100,7 +245,7 @@ interface BackupAPI { stop() runnable = Runnable { - Log.d("SYNC_API", "[$id] schedule success") + Log.d(LOG_KEY, "[$id] schedule success") onWork(input) } handler.postDelayed(runnable!!, throttleTimeMs) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index 6d6e4711..d6d9fee6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -5,13 +5,13 @@ import android.net.Uri import android.util.Log import androidx.browser.customtabs.CustomTabsIntent import androidx.fragment.app.FragmentActivity -import com.fasterxml.jackson.module.kotlin.readValue import com.google.api.client.auth.oauth2.AuthorizationCodeFlow import com.google.api.client.auth.oauth2.Credential import com.google.api.client.auth.oauth2.TokenResponse import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport import com.google.api.client.http.FileContent +import com.google.api.client.http.javanet.NetHttpTransport import com.google.api.client.json.gson.GsonFactory import com.google.api.client.util.store.MemoryDataStoreFactory import com.google.api.services.drive.Drive @@ -22,28 +22,33 @@ import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.BackupAPI +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.LOG_KEY import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API import com.lagradost.cloudstream3.syncproviders.InAppOAuth2APIManager import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.getBackup -import com.lagradost.cloudstream3.utils.BackupUtils.restore import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.DataStore -import com.lagradost.cloudstream3.utils.DataStore.removeKey import kotlinx.coroutines.Job import java.io.InputStream -import java.util.* +import java.util.Date -// improvements and ideas -// - add option to use proper oauth through google services one tap - would need google console project on behalf of cloudstream -// - encrypt data on drive - meh -// - choose what should be synced -// - having a button to run sync would be nice - its really just for user to feel "safe" -// - having two or more devices in use at same time results in racing conditions -// - restoring backup should update view models - maybe just first fragment is enough -// - move "https://chiff.github.io/cloudstream-sync/google-drive" to cs3 repo +/** + * ## Improvements and ideas + * + * | State | Priority | Description + * |---------:|:--------:|--------------------------------------------------------------------- + * | Progress | 2 | Restoring backup should update view models + * | Waiting | 2 | Add button to manually trigger sync + * | Waiting | 3 | Move "https://chiff.github.io/cloudstream-sync/google-drive" + * | Waiting | 3 | We should check what keys should really be restored. If user has multiple + * | | | devices with different settings that they want to keep we should respect that + * | Waiting | 4 | Implement backup before user quits application + * | Waiting | 5 | Choose what should be synced + * | Someday | 3 | Add option to use proper OAuth through Google Services One Tap + * | Someday | 5 | Encrypt data on Drive (low priority) + * | Solved | 1 | Racing conditions when multiple devices in use + */ class GoogleDriveApi(index: Int) : InAppOAuth2APIManager(index), BackupAPI { @@ -61,12 +66,12 @@ class GoogleDriveApi(index: Int) : override val requiresSecret = true override val requiresClientId = true override val defaultFilenameValue = "cloudstreamapp-sync-file" - override val defaultRedirectUrl = "https://chiff.github.io/cloudstream-sync/google-drive" + override val defaultRedirectUrl = "https://chiff.github.io/cloudstream-sync/google-drive" override var uploadJob: Job? = null - var tempAuthFlow: AuthorizationCodeFlow? = null - var lastBackupJson: String? = null + private var tempAuthFlow: AuthorizationCodeFlow? = null + private var lastBackupJson: String? = null ///////////////////////////////////////// ///////////////////////////////////////// @@ -163,49 +168,12 @@ class GoogleDriveApi(index: Int) : } override fun getLatestLoginData(): InAppOAuth2API.LoginData? { - return getValue(K.LOGIN_DATA) + return getValue(K.LOGIN_DATA) } ///////////////////////////////////////// ///////////////////////////////////////// // BackupAPI implementation - override fun Context.mergeBackup(incomingData: String) { - val currentData = getBackup() - val newData = DataStore.mapper.readValue(incomingData) - - getRedundantKeys(currentData, newData).forEach { - removeKey(it) - } - - restore( - newData, - restoreSettings = true, - restoreDataStore = true - ) - } - - // 🤮 - private fun getRedundantKeys( - old: BackupUtils.BackupFile, - new: BackupUtils.BackupFile - ): List = mutableListOf( - *getRedundant(old.settings._Bool, new.settings._Bool), - *getRedundant(old.settings._Long, new.settings._Long), - *getRedundant(old.settings._Float, new.settings._Float), - *getRedundant(old.settings._Int, new.settings._Int), - *getRedundant(old.settings._String, new.settings._String), - *getRedundant(old.settings._StringSet, new.settings._StringSet), - *getRedundant(old.datastore._Bool, new.datastore._Bool), - *getRedundant(old.datastore._Long, new.datastore._Long), - *getRedundant(old.datastore._Float, new.datastore._Float), - *getRedundant(old.datastore._Int, new.datastore._Int), - *getRedundant(old.datastore._String, new.datastore._String), - *getRedundant(old.datastore._StringSet, new.datastore._StringSet), - ) - - private fun getRedundant(old: Map?, new: Map?): Array = - old.orEmpty().keys.subtract(new.orEmpty().keys).toTypedArray() - override fun Context.createBackup(loginData: InAppOAuth2API.LoginData) { val drive = getDriveService() ?: return @@ -263,13 +231,14 @@ class GoogleDriveApi(index: Int) : try { val inputStream: InputStream = existingFile.executeMediaAsInputStream() val content: String = inputStream.bufferedReader().use { it.readText() } - Log.d("SYNC_API", "downloadSyncData merging") + Log.d(LOG_KEY, "downloadSyncData merging") ctx.mergeBackup(content) return - } catch (_: Exception) { + } catch (e: Exception) { + Log.e(LOG_KEY,"download failed", e) } } else { - Log.d("SYNC_API", "downloadSyncData file not exists") + Log.d(LOG_KEY, "downloadSyncData file not exists") uploadSyncData() } } @@ -306,16 +275,15 @@ class GoogleDriveApi(index: Int) : override fun uploadSyncData() { val ctx = AcraApplication.context ?: return val loginData = getLatestLoginData() ?: return - Log.d("SYNC_API", "uploadSyncData createBackup") + Log.d(LOG_KEY, "uploadSyncData createBackup") ctx.createBackup(loginData) } override fun shouldUpdate(changedKey: String, isSettings: Boolean): Boolean { val ctx = AcraApplication.context ?: return false - // would be smarter to check properties, but its called once in UPLOAD_THROTTLE seconds val newBackup = ctx.getBackup().toJson() - return lastBackupJson != newBackup + return compareJson(lastBackupJson ?: "", newBackup).failed } private fun getDriveService(): Drive? { @@ -338,12 +306,14 @@ class GoogleDriveApi(index: Int) : ) { if (uploadJob?.isActive == true) { uploadJob!!.invokeOnCompletion { - Log.d("SYNC_API", "upload is running, reschedule download") + Log.d(LOG_KEY, "upload is running, reschedule download") runDownloader() } } else { - Log.d("SYNC_API", "downloadSyncData will run") - downloadSyncData() + Log.d(LOG_KEY, "downloadSyncData will run") + ioSafe { + downloadSyncData() + } runDownloader() } } @@ -358,11 +328,11 @@ class GoogleDriveApi(index: Int) : } private fun getCredentialsFromStore(): Credential? { - val LOGIN_DATA = getLatestLoginData() - val TOKEN = getValue(K.TOKEN) + val loginDate = getLatestLoginData() + val token = getValue(K.TOKEN) - val credential = if (LOGIN_DATA != null && TOKEN != null) { - GAPI.getCredentials(TOKEN, LOGIN_DATA) + val credential = if (loginDate != null && token != null) { + GAPI.getCredentials(token, loginDate) } else { return null } @@ -385,8 +355,8 @@ class GoogleDriveApi(index: Int) : object GAPI { private const val DATA_STORE_ID = "gdrive_tokens" private val USED_SCOPES = listOf(DriveScopes.DRIVE_FILE) - val HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport() - val JSON_FACTORY = GsonFactory.getDefaultInstance() + val HTTP_TRANSPORT: NetHttpTransport = GoogleNetHttpTransport.newTrustedTransport() + val JSON_FACTORY: GsonFactory = GsonFactory.getDefaultInstance() fun createAuthFlow(clientId: String, clientSecret: String): GoogleAuthorizationCodeFlow = GoogleAuthorizationCodeFlow.Builder( diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 894dfdbf..98642429 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -28,7 +28,7 @@ import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.mvvm.* import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType @@ -38,6 +38,7 @@ import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAu import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage import com.lagradost.cloudstream3.utils.SubtitleHelper.languages @@ -56,8 +57,6 @@ import kotlinx.android.synthetic.main.player_select_source_and_subs.subtitles_cl import kotlinx.android.synthetic.main.player_select_tracks.* import kotlinx.coroutines.Job import java.util.* -import kotlin.collections.ArrayList -import kotlin.collections.HashMap class GeneratorPlayer : FullScreenPlayer() { companion object { @@ -665,7 +664,8 @@ class GeneratorPlayer : FullScreenPlayer() { } sourceDialog.subtitles_click_settings?.setOnClickListener { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx).attachListener().first + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + .attachBackupListener(ctx.getSyncPrefs()).self val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) val prefValues = ctx.resources.getStringArray(R.array.subtitles_encoding_values) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt index c0e2ee49..91661b92 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -25,11 +25,12 @@ import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.network.initClient -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.EasterEggMonke import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog @@ -141,7 +142,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { // Stores the real URI using download_path_key // Important that the URI is stored instead of filepath due to permissions. PreferenceManager.getDefaultSharedPreferences(context) - .attachListener().first + .attachBackupListener(context.getSyncPrefs()).self .edit() .putString(getString(R.string.download_path_key), uri.toString()) .apply() @@ -150,7 +151,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { // File path here is purely for cosmetic purposes in settings (file.filePath ?: uri.toString()).let { PreferenceManager.getDefaultSharedPreferences(context) - .attachListener().first + .attachBackupListener(context.getSyncPrefs()).self .edit() .putString(getString(R.string.download_path_pref), it) .apply() @@ -160,7 +161,8 @@ class SettingsGeneral : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settins_general, rootKey) - val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()).attachListener().first + val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + .attachBackupListener(requireContext().getSyncPrefs()).self fun getCurrent(): MutableList { return getKey>(USER_PROVIDER_API)?.toMutableList() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt index e065ef12..0bba69f8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt @@ -7,13 +7,14 @@ import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getFolderSize import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog @@ -28,7 +29,8 @@ class SettingsPlayer : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_player, rootKey) - val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()).attachListener().first + val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + .attachBackupListener(requireContext().getSyncPrefs()).self getPref(R.string.video_buffer_length_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.video_buffer_length_names) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt index e791365b..0095e673 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt @@ -6,20 +6,24 @@ import androidx.navigation.NavOptions import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener +import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API class SettingsProviders : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -31,7 +35,8 @@ class SettingsProviders : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_providers, rootKey) - val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()).attachListener().first + val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + .attachBackupListener(requireContext().getSyncPrefs()).self getPref(R.string.display_sub_key)?.setOnPreferenceClickListener { activity?.getApiDubstatusSettings()?.let { current -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt index b19c1ee5..4cd41570 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt @@ -8,12 +8,13 @@ import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog @@ -29,7 +30,8 @@ class SettingsUI : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settins_ui, rootKey) - val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()).attachListener().first + val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + .attachBackupListener(requireContext().getSyncPrefs()).self getPref(R.string.poster_ui_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.poster_ui_options) 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 3590e28e..823d1d11 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 @@ -14,19 +14,24 @@ import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.utils.BackupUtils.backup import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.VideoDownloadManager -import kotlinx.android.synthetic.main.logcat.* +import kotlinx.android.synthetic.main.logcat.clear_btt +import kotlinx.android.synthetic.main.logcat.close_btt +import kotlinx.android.synthetic.main.logcat.copy_btt +import kotlinx.android.synthetic.main.logcat.save_btt +import kotlinx.android.synthetic.main.logcat.text1 import okhttp3.internal.closeQuietly import java.io.BufferedReader import java.io.InputStreamReader @@ -127,7 +132,8 @@ class SettingsUpdates : PreferenceFragmentCompat() { } getPref(R.string.apk_installer_key)?.setOnPreferenceClickListener { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context).attachListener().first + val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context) + .attachBackupListener(it.context.getSyncPrefs()).self val prefNames = resources.getStringArray(R.array.apk_installer_pref) val prefValues = resources.getIntArray(R.array.apk_installer_values) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt index 63de8c96..82929fdd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt @@ -15,12 +15,15 @@ import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.settings.appLanguages import com.lagradost.cloudstream3.ui.settings.getCurrentLocale +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import kotlinx.android.synthetic.main.fragment_setup_language.* +import kotlinx.android.synthetic.main.fragment_setup_language.app_icon_image +import kotlinx.android.synthetic.main.fragment_setup_language.setup_root +import kotlinx.android.synthetic.main.fragment_setup_language.skip_btt import kotlinx.android.synthetic.main.fragment_setup_media.listview1 import kotlinx.android.synthetic.main.fragment_setup_media.next_btt @@ -43,7 +46,8 @@ class SetupFragmentLanguage : Fragment() { normalSafeApiCall { with(context) { if (this == null) return@normalSafeApiCall - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().first + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + .attachBackupListener(getSyncPrefs()).self val arrayAdapter = ArrayAdapter(this, R.layout.sort_bottom_single_choice) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt index db3d2e49..5133c919 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt @@ -10,9 +10,11 @@ import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import kotlinx.android.synthetic.main.fragment_setup_layout.* +import kotlinx.android.synthetic.main.fragment_setup_layout.acra_switch +import kotlinx.android.synthetic.main.fragment_setup_layout.crash_reporting_text import kotlinx.android.synthetic.main.fragment_setup_media.listview1 import kotlinx.android.synthetic.main.fragment_setup_media.next_btt import kotlinx.android.synthetic.main.fragment_setup_media.prev_btt @@ -34,7 +36,8 @@ class SetupFragmentLayout : Fragment() { with(context) { if (this == null) return - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().first + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + .attachBackupListener(getSyncPrefs()).self val prefNames = resources.getStringArray(R.array.app_layout) val prefValues = resources.getIntArray(R.array.app_layout_values) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt index 5e8a42ae..17f05c5b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt @@ -12,11 +12,15 @@ import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.DataStore.removeKey -import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import kotlinx.android.synthetic.main.fragment_setup_media.* +import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API +import kotlinx.android.synthetic.main.fragment_setup_media.listview1 +import kotlinx.android.synthetic.main.fragment_setup_media.next_btt +import kotlinx.android.synthetic.main.fragment_setup_media.prev_btt +import kotlinx.android.synthetic.main.fragment_setup_media.setup_root class SetupFragmentMedia : Fragment() { @@ -33,7 +37,8 @@ class SetupFragmentMedia : Fragment() { with(context) { if (this == null) return - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().first + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + .attachBackupListener(getSyncPrefs()).self val arrayAdapter = ArrayAdapter(this, R.layout.sort_bottom_single_choice) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt index 74b1d77e..1ba15e3b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt @@ -14,10 +14,14 @@ import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import kotlinx.android.synthetic.main.fragment_setup_media.* +import kotlinx.android.synthetic.main.fragment_setup_media.listview1 +import kotlinx.android.synthetic.main.fragment_setup_media.next_btt +import kotlinx.android.synthetic.main.fragment_setup_media.prev_btt +import kotlinx.android.synthetic.main.fragment_setup_media.setup_root class SetupFragmentProviderLanguage : Fragment() { override fun onCreateView( @@ -34,7 +38,8 @@ class SetupFragmentProviderLanguage : Fragment() { with(context) { if (this == null) return - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().first + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + .attachBackupListener(getSyncPrefs()).self val arrayAdapter = ArrayAdapter(this, R.layout.sort_bottom_single_choice) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt index 1799a412..e02b6b76 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt @@ -27,8 +27,9 @@ import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog @@ -448,7 +449,7 @@ class SubtitlesFragment : Fragment() { subtitles_filter_sub_lang?.setOnCheckedChangeListener { _, b -> context?.let { ctx -> PreferenceManager.getDefaultSharedPreferences(ctx) - .attachListener().first + .attachBackupListener(ctx.getSyncPrefs()).self .edit() .putBoolean(getString(R.string.filter_sub_lang_key), b) .apply() 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 002d617c..a43447a9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -14,10 +14,12 @@ import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.module.kotlin.readValue import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainActivity.Companion.afterBackupRestoreEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.plugins.PLUGINS_KEY import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL +import com.lagradost.cloudstream3.syncproviders.BackupAPI import com.lagradost.cloudstream3.syncproviders.InAppOAuth2APIManager import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_TOKEN_KEY @@ -33,7 +35,9 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.DataStore.mapper +import com.lagradost.cloudstream3.utils.DataStore.removeKeyRaw import com.lagradost.cloudstream3.utils.DataStore.setKeyRaw import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.requestRW @@ -46,6 +50,11 @@ import java.text.SimpleDateFormat import java.util.* object BackupUtils { + enum class RestoreSource { + DATA, SETTINGS, SYNC; + + val prefix = "$name/" + } /** * No sensitive or breaking data in the backup @@ -90,14 +99,25 @@ object BackupUtils { data class BackupFile( @JsonProperty("datastore") val datastore: BackupVars, - @JsonProperty("settings") val settings: BackupVars + @JsonProperty("settings") val settings: BackupVars, + @JsonProperty("sync-meta") val syncMeta: BackupVars ) @Suppress("UNCHECKED_CAST") fun Context.getBackup(): BackupFile { + val syncDataPrefs = getSyncPrefs().all.filter { it.key.isTransferable() } val allData = getSharedPrefs().all.filter { it.key.isTransferable() } val allSettings = getDefaultSharedPrefs().all.filter { it.key.isTransferable() } + val syncData = BackupVars( + syncDataPrefs.filter { it.value is Boolean } as? Map, + syncDataPrefs.filter { it.value is Int } as? Map, + syncDataPrefs.filter { it.value is String } as? Map, + syncDataPrefs.filter { it.value is Float } as? Map, + syncDataPrefs.filter { it.value is Long } as? Map, + syncDataPrefs.filter { it.value as? Set != null } as? Map> + ) + val allDataSorted = BackupVars( allData.filter { it.value is Boolean } as? Map, allData.filter { it.value is Int } as? Map, @@ -118,32 +138,44 @@ object BackupUtils { return BackupFile( allDataSorted, - allSettingsSorted + allSettingsSorted, + syncData ) } @WorkerThread fun Context.restore( backupFile: BackupFile, + restoreKeys: List? = null, restoreSettings: Boolean, - restoreDataStore: Boolean + restoreDataStore: Boolean, + restoreSyncData: Boolean ) { + if (restoreSyncData) { + restoreMap(backupFile.syncMeta._Bool, RestoreSource.SYNC, restoreKeys) + restoreMap(backupFile.syncMeta._Int, RestoreSource.SYNC, restoreKeys) + restoreMap(backupFile.syncMeta._String, RestoreSource.SYNC, restoreKeys) + restoreMap(backupFile.syncMeta._Float, RestoreSource.SYNC, restoreKeys) + restoreMap(backupFile.syncMeta._Long, RestoreSource.SYNC, restoreKeys) + restoreMap(backupFile.syncMeta._StringSet, RestoreSource.SYNC, restoreKeys) + } + if (restoreSettings) { - restoreMap(backupFile.settings._Bool, true) - restoreMap(backupFile.settings._Int, true) - restoreMap(backupFile.settings._String, true) - restoreMap(backupFile.settings._Float, true) - restoreMap(backupFile.settings._Long, true) - restoreMap(backupFile.settings._StringSet, true) + restoreMap(backupFile.settings._Bool, RestoreSource.SETTINGS, restoreKeys) + restoreMap(backupFile.settings._Int, RestoreSource.SETTINGS, restoreKeys) + restoreMap(backupFile.settings._String, RestoreSource.SETTINGS, restoreKeys) + restoreMap(backupFile.settings._Float, RestoreSource.SETTINGS, restoreKeys) + restoreMap(backupFile.settings._Long, RestoreSource.SETTINGS, restoreKeys) + restoreMap(backupFile.settings._StringSet, RestoreSource.SETTINGS, restoreKeys) } if (restoreDataStore) { - restoreMap(backupFile.datastore._Bool) - restoreMap(backupFile.datastore._Int) - restoreMap(backupFile.datastore._String) - restoreMap(backupFile.datastore._Float) - restoreMap(backupFile.datastore._Long) - restoreMap(backupFile.datastore._StringSet) + restoreMap(backupFile.datastore._Bool, RestoreSource.DATA, restoreKeys) + restoreMap(backupFile.datastore._Int, RestoreSource.DATA, restoreKeys) + restoreMap(backupFile.datastore._String, RestoreSource.DATA, restoreKeys) + restoreMap(backupFile.datastore._Float, RestoreSource.DATA, restoreKeys) + restoreMap(backupFile.datastore._Long, RestoreSource.DATA, restoreKeys) + restoreMap(backupFile.datastore._StringSet, RestoreSource.DATA, restoreKeys) } } @@ -232,13 +264,11 @@ object BackupUtils { val input = activity.contentResolver.openInputStream(uri) ?: return@ioSafe - val restoredValue = - mapper.readValue(input) - activity.restore( - restoredValue, + mapper.readValue(input), restoreSettings = true, - restoreDataStore = true + restoreDataStore = true, + restoreSyncData = true ) activity.runOnUiThread { activity.recreate() } } catch (e: Exception) { @@ -280,10 +310,44 @@ object BackupUtils { private fun Context.restoreMap( map: Map?, - isEditingAppSettings: Boolean = false + restoreSource: RestoreSource, + restoreKeys: List? ) { - map?.filter { it.key.isTransferable() }?.forEach { - setKeyRaw(it.key, it.value, isEditingAppSettings) + val restoreOnlyThese = mutableListOf() + val successfulRestore = mutableListOf() + + if (!restoreKeys.isNullOrEmpty()) { + val prefixToMatch = "${BackupAPI.SYNC_HISTORY_PREFIX}${restoreSource.prefix}" + + val restore = restoreKeys.filter { + it.startsWith(prefixToMatch) + }.map { + it.removePrefix(prefixToMatch) + } + + restoreOnlyThese.addAll(restore) + } + + map?.filter { + var isTransferable = it.key.isTransferable() + var canRestore = isTransferable + if (restoreOnlyThese.isNotEmpty()) { + canRestore = canRestore && restoreOnlyThese.contains(it.key) + } + + if (isTransferable && canRestore) { + successfulRestore.add(it.key) + } + + canRestore + }?.forEach { + setKeyRaw(it.key, it.value, restoreSource) + } + + // we must remove keys that are not present + if (!restoreKeys.isNullOrEmpty()) { + var removedKeys = restoreOnlyThese - successfulRestore.toSet() + removedKeys.forEach { removeKeyRaw(it, restoreSource) } } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index 8c52e221..0edaa707 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.module.kotlin.KotlinModule import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.BackupAPI +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.logHistoryChanged const val DOWNLOAD_HEADER_CACHE = "download_header_cache" @@ -18,6 +19,7 @@ const val USER_SELECTED_HOMEPAGE_API = "home_api_used" const val USER_PROVIDER_API = "user_custom_sites" const val PREFERENCES_NAME = "rebuild_preference" +const val SYNC_PREFERENCES_NAME = "rebuild_sync_preference" object DataStore { val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule()) @@ -31,6 +33,14 @@ object DataStore { return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) } + private fun getSyncPreferences(context: Context): SharedPreferences { + return context.getSharedPreferences(SYNC_PREFERENCES_NAME, Context.MODE_PRIVATE) + } + + fun Context.getSyncPrefs(): SharedPreferences { + return getSyncPreferences(this) + } + fun Context.getSharedPrefs(): SharedPreferences { return getPreferences(this) } @@ -38,11 +48,14 @@ object DataStore { fun getFolderName(folder: String, path: String): String { return "${folder}/${path}" } - - fun Context.setKeyRaw(path: String, value: T, isEditingAppSettings: Boolean = false) { + fun Context.setKeyRaw(path: String, value: T, restoreSource: BackupUtils.RestoreSource) { try { - val editor: SharedPreferences.Editor = - if (isEditingAppSettings) getDefaultSharedPrefs().edit() else getSharedPrefs().edit() + val editor = when (restoreSource) { + BackupUtils.RestoreSource.DATA -> getSharedPrefs().edit() + BackupUtils.RestoreSource.SETTINGS -> getDefaultSharedPrefs().edit() + BackupUtils.RestoreSource.SYNC -> getSyncPrefs().edit() + } + when (value) { is Boolean -> editor.putBoolean(path, value) is Int -> editor.putInt(path, value) @@ -56,6 +69,17 @@ object DataStore { logError(e) } } + fun Context.removeKeyRaw(path: String, restoreSource: BackupUtils.RestoreSource) { + try { + when (restoreSource) { + BackupUtils.RestoreSource.DATA -> getSharedPrefs().edit() + BackupUtils.RestoreSource.SETTINGS -> getDefaultSharedPrefs().edit() + BackupUtils.RestoreSource.SYNC -> getSyncPrefs().edit() + }.remove(path).apply() + } catch (e: Exception) { + logError(e) + } + } fun Context.getDefaultSharedPrefs(): SharedPreferences { return PreferenceManager.getDefaultSharedPreferences(this) @@ -85,7 +109,9 @@ object DataStore { val editor: SharedPreferences.Editor = prefs.edit() editor.remove(path) editor.apply() - backupScheduler.work(Pair(path, false)) + + getSyncPrefs().logHistoryChanged(path, BackupUtils.RestoreSource.DATA) + backupScheduler.work(BackupAPI.PreferencesSchedulerData(path, false)) } } catch (e: Exception) { logError(e) @@ -105,7 +131,9 @@ object DataStore { val editor: SharedPreferences.Editor = getSharedPrefs().edit() editor.putString(path, mapper.writeValueAsString(value)) editor.apply() - backupScheduler.work(Pair(path, false)) + + getSyncPrefs().logHistoryChanged(path, BackupUtils.RestoreSource.DATA) + backupScheduler.work(BackupAPI.PreferencesSchedulerData(path, false)) } catch (e: Exception) { logError(e) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index 09cf4138..8a588d53 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri +import android.text.TextUtils import android.util.Log import android.widget.Toast import androidx.appcompat.app.AlertDialog @@ -14,18 +15,18 @@ import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.BufferedSink import okio.buffer import okio.sink -import java.io.File -import android.text.TextUtils -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachListener -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus import java.io.BufferedReader +import java.io.File import java.io.IOException import java.io.InputStreamReader @@ -74,7 +75,8 @@ class InAppUpdater { private suspend fun Activity.getAppUpdate(): Update { return try { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().first + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + .attachBackupListener(getSyncPrefs()).self if (settingsManager.getBoolean( getString(R.string.prerelease_update_key), resources.getBoolean(R.bool.is_prerelease) @@ -255,7 +257,9 @@ class InAppUpdater { * @param checkAutoUpdate if the update check was launched automatically **/ suspend fun Activity.runAutoUpdate(checkAutoUpdate: Boolean = true): Boolean { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().first + val settingsManager = + PreferenceManager.getDefaultSharedPreferences(this) + .attachBackupListener(getSyncPrefs()).self if (!checkAutoUpdate || settingsManager.getBoolean( getString(R.string.auto_update_key), @@ -265,7 +269,8 @@ class InAppUpdater { val update = getAppUpdate() if ( update.shouldUpdate && - update.updateURL != null) { + update.updateURL != null + ) { // Check if update should be skipped val updateNodeId = From e1e039b58c2a20029fcef1fa96d9adcae809b5fe Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Sat, 22 Apr 2023 22:11:40 +0200 Subject: [PATCH 08/37] feat: add remote sync capability - refactor and improve syncing when multiple devices are online --- .../main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt | 1 - 1 file changed, 1 deletion(-) 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 a43447a9..ddbc17d6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -14,7 +14,6 @@ import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.module.kotlin.readValue import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.MainActivity.Companion.afterBackupRestoreEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.plugins.PLUGINS_KEY From fe36b69758dd95c23bc4f8557564fa4b4d141e8a Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Mon, 1 May 2023 18:01:26 +0200 Subject: [PATCH 09/37] feat: add remote sync capability - refactor, refresh ui on restore and improve data restore (pt.2) --- .../lagradost/cloudstream3/MainActivity.kt | 1 + .../cloudstream3/syncproviders/BackupAPI.kt | 140 ++++-------------- .../syncproviders/InAppOAuth2API.kt | 4 +- .../syncproviders/providers/GoogleDriveApi.kt | 116 +++++++++------ .../cloudstream3/ui/home/HomeFragment.kt | 5 +- .../ui/library/LibraryFragment.kt | 25 ++++ .../cloudstream3/ui/player/GeneratorPlayer.kt | 2 +- .../ui/settings/SettingsGeneral.kt | 2 +- .../ui/settings/SettingsPlayer.kt | 2 +- .../ui/settings/SettingsProviders.kt | 2 +- .../cloudstream3/ui/settings/SettingsUI.kt | 2 +- .../ui/settings/SettingsUpdates.kt | 2 +- .../ui/setup/SetupFragmentLanguage.kt | 2 +- .../ui/setup/SetupFragmentLayout.kt | 2 +- .../ui/setup/SetupFragmentMedia.kt | 2 +- .../ui/setup/SetupFragmentProviderLanguage.kt | 2 +- .../ui/subtitles/SubtitlesFragment.kt | 2 +- .../cloudstream3/utils/BackupUtils.kt | 130 +++++++++------- .../lagradost/cloudstream3/utils/DataStore.kt | 18 ++- .../cloudstream3/utils/InAppUpdater.kt | 2 +- .../lagradost/cloudstream3/utils/Scheduler.kt | 127 ++++++++++++++++ 21 files changed, 363 insertions(+), 227 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index d054f504..4dc939d6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -264,6 +264,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { val mainPluginsLoadedEvent = Event() // homepage api, used to speed up time to load for homepage val afterRepositoryLoadedEvent = Event() + val afterBackupRestoreEvent = Event() // kinda shitty solution, but cant com main->home otherwise for popups val bookmarksUpdatedEvent = Event() diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt index 0f65695c..f27d43a9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -2,8 +2,6 @@ package com.lagradost.cloudstream3.syncproviders import android.content.Context import android.content.SharedPreferences -import android.os.Handler -import android.os.Looper import android.util.Log import com.fasterxml.jackson.module.kotlin.readValue import com.lagradost.cloudstream3.mvvm.launchSafe @@ -11,6 +9,7 @@ import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.getBackup import com.lagradost.cloudstream3.utils.BackupUtils.restore import com.lagradost.cloudstream3.utils.DataStore +import com.lagradost.cloudstream3.utils.Scheduler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -27,6 +26,7 @@ interface BackupAPI { ) data class PreferencesSchedulerData( + val prefs: SharedPreferences, val storeKey: String, val isSettings: Boolean ) @@ -46,41 +46,9 @@ interface BackupAPI { // on factors like: live devices, quota limits, etc val UPLOAD_THROTTLE = 10.seconds val DOWNLOAD_THROTTLE = 60.seconds - // add to queue may be called frequently private val ioScope = CoroutineScope(Dispatchers.IO) - fun createBackupScheduler() = Scheduler( - UPLOAD_THROTTLE.inWholeMilliseconds - ) { input -> - if (input == null) { - throw IllegalStateException() - } - - AccountManager.BackupApis.forEach { it.addToQueue(input.storeKey, input.isSettings) } - } - - // Common usage is `val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().self` - // which means it is mostly used for settings preferences, therefore we use `isSettings: Boolean = true`, be careful - // to turn it of if you need to directly access `context.getSharedPreferences` (without using DataStore) - - fun SharedPreferences.attachBackupListener( - isSettings: Boolean = true, - syncPrefs: SharedPreferences? = null - ): SharedPreferencesWithListener { - val scheduler = createBackupScheduler() - registerOnSharedPreferenceChangeListener { _, storeKey -> - syncPrefs?.logHistoryChanged(storeKey, BackupUtils.RestoreSource.SETTINGS) - scheduler.work(PreferencesSchedulerData(storeKey, isSettings)) - } - - return SharedPreferencesWithListener(this, scheduler) - } - - fun SharedPreferences.attachBackupListener(syncPrefs: SharedPreferences?): SharedPreferencesWithListener { - return attachBackupListener(true, syncPrefs) - } - fun SharedPreferences.logHistoryChanged(path: String, source: BackupUtils.RestoreSource) { edit().putLong("$SYNC_HISTORY_PREFIX${source.prefix}$path", System.currentTimeMillis()) .apply() @@ -89,11 +57,12 @@ interface BackupAPI { /** * Should download data from API and call Context.mergeBackup(incomingData: String). If data - * does not exist on the api uploadSyncData() is recommended to call + * does not exist on the api uploadSyncData() is recommended to call. Should be called with + * overwrite=true when user ads new account so it would accept changes from API * @see Context.mergeBackup * @see uploadSyncData */ - fun downloadSyncData() + fun downloadSyncData(overwrite: Boolean) /** * Should upload data to API and call Context.createBackup(loginData: LOGIN_DATA) @@ -103,22 +72,24 @@ interface BackupAPI { fun Context.createBackup(loginData: LOGIN_DATA) - fun Context.mergeBackup(incomingData: String) { - val currentData = getBackup() + fun Context.mergeBackup(incomingData: String, overwrite: Boolean) { val newData = DataStore.mapper.readValue(incomingData) + if (overwrite) { + Log.d(LOG_KEY, "overwriting data") + restore(newData) - val keysToUpdate = getKeysToUpdate(currentData, newData) - if (keysToUpdate.isEmpty()) { return } - restore( - newData, - keysToUpdate, - restoreSettings = true, - restoreDataStore = true, - restoreSyncData = true - ) + val keysToUpdate = getKeysToUpdate(getBackup(), newData) + if (keysToUpdate.isEmpty()) { + Log.d(LOG_KEY, "remote data is up to date, sync not needed") + return + } + + + Log.d(LOG_KEY, incomingData) + restore(newData, keysToUpdate) } var uploadJob: Job? @@ -153,7 +124,7 @@ interface BackupAPI { } val failed = result?.failed() ?: true - Log.d(LOG_KEY, "JSON comparison took $executionTime ms, compareFailed=$failed") + Log.d(LOG_KEY, "JSON comparison took $executionTime ms, compareFailed=$failed, result=$result") return JSONComparison(failed, result) } @@ -161,34 +132,25 @@ interface BackupAPI { fun getKeysToUpdate( currentData: BackupUtils.BackupFile, newData: BackupUtils.BackupFile - ): List { - val currentSync = currentData.syncMeta._Long.orEmpty().filter { - it.key.startsWith(SYNC_HISTORY_PREFIX) - } - - val newSync = newData.syncMeta._Long.orEmpty().filter { - it.key.startsWith(SYNC_HISTORY_PREFIX) - } + ): Set { + val currentSync = getSyncKeys(currentData) + val newSync = getSyncKeys(newData) val changedKeys = newSync.filter { - val localTimestamp = if (currentSync[it.key] != null) { - currentSync[it.key]!! - } else { - 0L - } - + val localTimestamp = currentSync[it.key] ?: 0L it.value > localTimestamp }.keys - val onlyLocalKeys = currentSync.keys.filter { !newSync.containsKey(it) } - val missingKeys = getMissingKeys(currentData, newData) - changedKeys - return mutableListOf( - *missingKeys.toTypedArray(), - *onlyLocalKeys.toTypedArray(), - *changedKeys.toTypedArray() - ) + val onlyLocalKeys = currentSync.keys.filter { !newSync.containsKey(it) } + val missingKeys = getMissingKeys(currentData, newData) + + return (missingKeys + onlyLocalKeys + changedKeys).toSet() } + private fun getSyncKeys(data: BackupUtils.BackupFile) = + data.syncMeta._Long.orEmpty().filter { it.key.startsWith(SYNC_HISTORY_PREFIX) } + + // 🤮 private fun getMissingKeys( old: BackupUtils.BackupFile, @@ -211,44 +173,4 @@ interface BackupAPI { private fun getMissing(old: Map?, new: Map?): Array = new.orEmpty().keys.subtract(old.orEmpty().keys).toTypedArray() - class Scheduler( - private val throttleTimeMs: Long, - private val onWork: (INPUT?) -> Unit - ) { - private companion object { - var SCHEDULER_ID = 1 - } - - private val id = SCHEDULER_ID++ - private val handler = Handler(Looper.getMainLooper()) - private var runnable: Runnable? = null - - fun work(input: INPUT? = null) { - Log.d(LOG_KEY, "[$id] wants to schedule [${input}]") - throttle(input) - } - - fun workNow(input: INPUT? = null) { - Log.d(LOG_KEY, "[$id] runs immediate [${input}]") - stop() - onWork(input) - } - - fun stop() { - runnable?.let { - handler.removeCallbacks(it) - runnable = null - } - } - - private fun throttle(input: INPUT?) { - stop() - - runnable = Runnable { - Log.d(LOG_KEY, "[$id] schedule success") - onWork(input) - } - handler.postDelayed(runnable!!, throttleTimeMs) - } - } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt index 5af8ff51..2d986d99 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt @@ -35,7 +35,9 @@ interface InAppOAuth2API : OAuth2API { abstract class InAppOAuth2APIManager(defIndex: Int) : AccountManager(defIndex), InAppOAuth2API { enum class K { LOGIN_DATA, - TOKEN; + IS_READY, + TOKEN, + ; val value: String = "data_oauth2_$name" } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index d6d9fee6..1a18b9a7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -28,6 +28,7 @@ import com.lagradost.cloudstream3.syncproviders.InAppOAuth2APIManager import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.BackupUtils.getBackup import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Scheduler import kotlinx.coroutines.Job import java.io.InputStream import java.util.Date @@ -38,16 +39,16 @@ import java.util.Date * * | State | Priority | Description * |---------:|:--------:|--------------------------------------------------------------------- - * | Progress | 2 | Restoring backup should update view models + * | Progress | 1 | Check if data was really changed when calling backupscheduler.work then + * | | | dont update sync meta if not needed * | Waiting | 2 | Add button to manually trigger sync * | Waiting | 3 | Move "https://chiff.github.io/cloudstream-sync/google-drive" - * | Waiting | 3 | We should check what keys should really be restored. If user has multiple - * | | | devices with different settings that they want to keep we should respect that * | Waiting | 4 | Implement backup before user quits application - * | Waiting | 5 | Choose what should be synced + * | Waiting | 5 | Choose what should be synced and recheck `invalidKeys` in createBackupScheduler * | Someday | 3 | Add option to use proper OAuth through Google Services One Tap * | Someday | 5 | Encrypt data on Drive (low priority) * | Solved | 1 | Racing conditions when multiple devices in use + * | Solved | 2 | Restoring backup should update view models */ class GoogleDriveApi(index: Int) : InAppOAuth2APIManager(index), @@ -107,8 +108,9 @@ class GoogleDriveApi(index: Int) : ) storeValue(K.TOKEN, googleTokenResponse) - runDownloader(true) + runDownloader(runNow = true, overwrite = true) + storeValue(K.IS_READY, true) tempAuthFlow = null return true } @@ -147,6 +149,7 @@ class GoogleDriveApi(index: Int) : switchToNewAccount() } + storeValue(K.IS_READY, false) storeValue(K.LOGIN_DATA, data) val authFlow = GAPI.createAuthFlow(data.clientId, data.secret) @@ -211,7 +214,7 @@ class GoogleDriveApi(index: Int) : } } - override fun downloadSyncData() { + override fun downloadSyncData(overwrite: Boolean) { val ctx = AcraApplication.context ?: return val drive = getDriveService() ?: return val loginData = getLatestLoginData() ?: return @@ -220,7 +223,8 @@ class GoogleDriveApi(index: Int) : val existingFile = if (existingFileId != null) { try { drive.files().get(existingFileId) - } catch (_: Exception) { + } catch (e: Exception) { + Log.e(LOG_KEY, "Could not find file for id $existingFileId", e) null } } else { @@ -232,19 +236,27 @@ class GoogleDriveApi(index: Int) : val inputStream: InputStream = existingFile.executeMediaAsInputStream() val content: String = inputStream.bufferedReader().use { it.readText() } Log.d(LOG_KEY, "downloadSyncData merging") - ctx.mergeBackup(content) + ctx.mergeBackup(content, overwrite) return } catch (e: Exception) { - Log.e(LOG_KEY,"download failed", e) + Log.e(LOG_KEY, "download failed", e) } - } else { - Log.d(LOG_KEY, "downloadSyncData file not exists") - uploadSyncData() } + + // if failed + Log.d(LOG_KEY, "downloadSyncData file not exists") + uploadSyncData() } private fun getOrCreateSyncFileId(drive: Drive, loginData: InAppOAuth2API.LoginData): String? { - val existingFileId: String? = loginData.syncFileId ?: drive + if (loginData.syncFileId != null) { + val verified = drive.files().get(loginData.syncFileId) + if (verified != null) { + return loginData.syncFileId + } + } + + val existingFileId: String? = drive .files() .list() .setQ("name='${loginData.fileName}' and trashed=false") @@ -253,29 +265,38 @@ class GoogleDriveApi(index: Int) : ?.getOrNull(0) ?.id - if (loginData.syncFileId == null) { - if (existingFileId != null) { - loginData.syncFileId = existingFileId - storeValue(K.LOGIN_DATA, loginData) + if (existingFileId != null) { + loginData.syncFileId = existingFileId + storeValue(K.LOGIN_DATA, loginData) - return existingFileId - } - - return null + return existingFileId } - val verifyId = drive.files().get(existingFileId) - return if (verifyId == null) { - return null - } else { - existingFileId - } + return null } override fun uploadSyncData() { - val ctx = AcraApplication.context ?: return - val loginData = getLatestLoginData() ?: return - Log.d(LOG_KEY, "uploadSyncData createBackup") + val canUpload = getValue(K.IS_READY) + if (canUpload != true) { + Log.d(LOG_KEY, "uploadSyncData is not ready yet") + return + } + + val ctx = AcraApplication.context + val loginData = getLatestLoginData() + + if (ctx == null) { + Log.d(LOG_KEY, "uploadSyncData cannot run (ctx)") + return + } + + + if (loginData == null) { + Log.d(LOG_KEY, "uploadSyncData cannot run (loginData)") + return + } + + Log.d(LOG_KEY, "uploadSyncData will run") ctx.createBackup(loginData) } @@ -301,29 +322,28 @@ class GoogleDriveApi(index: Int) : ///////////////////////////////////////// ///////////////////////////////////////// // Internal - private val continuousDownloader = BackupAPI.Scheduler( - BackupAPI.DOWNLOAD_THROTTLE.inWholeMilliseconds - ) { - if (uploadJob?.isActive == true) { - uploadJob!!.invokeOnCompletion { - Log.d(LOG_KEY, "upload is running, reschedule download") + private val continuousDownloader = Scheduler( + BackupAPI.DOWNLOAD_THROTTLE.inWholeMilliseconds, + { overwrite -> + if (uploadJob?.isActive == true) { + uploadJob!!.invokeOnCompletion { + Log.d(LOG_KEY, "upload is running, reschedule download") + runDownloader(false, overwrite == true) + } + } else { + Log.d(LOG_KEY, "downloadSyncData will run") + ioSafe { + downloadSyncData(overwrite == true) + } runDownloader() } - } else { - Log.d(LOG_KEY, "downloadSyncData will run") - ioSafe { - downloadSyncData() - } - runDownloader() - } - } + }) - private fun runDownloader(runNow: Boolean = false) { + private fun runDownloader(runNow: Boolean = false, overwrite: Boolean = false) { if (runNow) { - continuousDownloader.workNow() + continuousDownloader.workNow(overwrite) } else { - continuousDownloader.work() - + continuousDownloader.work(overwrite) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index 5cf6fc8e..1545bc32 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -31,6 +31,7 @@ import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.MainActivity.Companion.afterBackupRestoreEvent import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.MainActivity.Companion.bookmarksUpdatedEvent import com.lagradost.cloudstream3.MainActivity.Companion.mainPluginsLoadedEvent @@ -477,16 +478,18 @@ class HomeFragment : Fragment() { bookmarksUpdatedEvent += ::bookmarksUpdated afterPluginsLoadedEvent += ::afterPluginsLoaded mainPluginsLoadedEvent += ::afterMainPluginsLoaded + afterBackupRestoreEvent += ::reloadStored } override fun onStop() { bookmarksUpdatedEvent -= ::bookmarksUpdated afterPluginsLoadedEvent -= ::afterPluginsLoaded mainPluginsLoadedEvent -= ::afterMainPluginsLoaded + afterBackupRestoreEvent -= ::reloadStored super.onStop() } - private fun reloadStored() { + private fun reloadStored(unused: Unit = Unit) { homeViewModel.loadResumeWatching() val list = EnumSet.noneOf(WatchType::class.java) getKey(HOME_BOOKMARK_VALUE_LIST)?.map { WatchType.fromInternalId(it) }?.let { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt index d7c06c4e..bb336d3e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt @@ -6,6 +6,7 @@ import android.content.res.Configuration import android.os.Bundle import android.os.Handler import android.os.Looper +import android.util.Log import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View @@ -21,10 +22,12 @@ import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.syncproviders.BackupAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment @@ -38,6 +41,7 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import kotlinx.android.synthetic.main.fragment_library.* +import org.checkerframework.framework.qual.Unused import kotlin.math.abs const val LIBRARY_FOLDER = "library_folder" @@ -76,9 +80,15 @@ class LibraryFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { + MainActivity.afterBackupRestoreEvent += ::onNewSyncData return inflater.inflate(R.layout.fragment_library, container, false) } + override fun onDestroyView() { + super.onDestroyView() + MainActivity.afterBackupRestoreEvent -= ::onNewSyncData + } + override fun onSaveInstanceState(outState: Bundle) { viewpager?.currentItem?.let { currentItem -> outState.putInt(VIEWPAGER_ITEM_KEY, currentItem) @@ -386,6 +396,21 @@ class LibraryFragment : Fragment() { (viewpager.adapter as? ViewpagerAdapter)?.rebind() super.onConfigurationChanged(newConfig) } + + override fun onResume() { + super.onResume() + MainActivity.afterBackupRestoreEvent += ::onNewSyncData + } + + override fun onStop() { + super.onStop() + MainActivity.afterBackupRestoreEvent -= ::onNewSyncData + } + + private fun onNewSyncData(unused: Unit) { + Log.d(BackupAPI.LOG_KEY, "will reload pages") + libraryViewModel.reloadPages(true) + } } class MenuSearchView(context: Context) : SearchView(context) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 98642429..a5cd0ab7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -28,7 +28,7 @@ import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.mvvm.* import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt index 91661b92..0fb1c2d8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -25,7 +25,7 @@ import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.network.initClient -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.EasterEggMonke import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt index 0bba69f8..c5d63846 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt @@ -7,7 +7,7 @@ import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getFolderSize import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt index 0095e673..0f48143d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt @@ -14,7 +14,7 @@ import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt index 4cd41570..ef347b0e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt @@ -8,7 +8,7 @@ import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom 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 823d1d11..61b3b5b5 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 @@ -14,7 +14,7 @@ import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt index 82929fdd..f205fa3a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt @@ -15,7 +15,7 @@ import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.settings.appLanguages import com.lagradost.cloudstream3.ui.settings.getCurrentLocale import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt index 5133c919..05b4aa8e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt @@ -10,7 +10,7 @@ import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import kotlinx.android.synthetic.main.fragment_setup_layout.acra_switch diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt index 17f05c5b..cfaa4c76 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt @@ -12,7 +12,7 @@ import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.DataStore.removeKey import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt index 1ba15e3b..cdd769df 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt @@ -14,7 +14,7 @@ import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt index e02b6b76..e7319d50 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt @@ -27,7 +27,7 @@ import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import com.lagradost.cloudstream3.utils.DataStore.setKey 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 ddbc17d6..2ef3e2da 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -6,6 +6,7 @@ import android.content.Context import android.net.Uri import android.os.Build import android.provider.MediaStore +import android.util.Log import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts @@ -14,6 +15,7 @@ import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.module.kotlin.readValue import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainActivity.Companion.afterBackupRestoreEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.plugins.PLUGINS_KEY @@ -70,6 +72,7 @@ object BackupUtils { MAL_UNIXTIME_KEY, MAL_USER_KEY, InAppOAuth2APIManager.K.TOKEN.value, + InAppOAuth2APIManager.K.IS_READY.value, // The plugins themselves are not backed up PLUGINS_KEY, @@ -87,6 +90,16 @@ object BackupUtils { private var restoreFileSelector: ActivityResultLauncher>? = null // Kinda hack, but I couldn't think of a better way + data class RestoreMapData( + val wantToRestore: MutableSet = mutableSetOf(), + val successfulRestore: MutableSet = mutableSetOf() + ) { + fun addAll(data: RestoreMapData) { + wantToRestore.addAll(data.wantToRestore) + successfulRestore.addAll(data.successfulRestore) + } + } + data class BackupVars( @JsonProperty("_Bool") val _Bool: Map?, @JsonProperty("_Int") val _Int: Map?, @@ -99,8 +112,32 @@ object BackupUtils { data class BackupFile( @JsonProperty("datastore") val datastore: BackupVars, @JsonProperty("settings") val settings: BackupVars, - @JsonProperty("sync-meta") val syncMeta: BackupVars - ) + @JsonProperty("sync-meta") val syncMeta: BackupVars, + ) { + fun restore( + ctx: Context, + source: RestoreSource, + restoreKeys: Set? = null + ): RestoreMapData { + val data = getData(source) + val successfulRestore = RestoreMapData() + + successfulRestore.addAll(ctx.restoreMap(data._Bool, source, restoreKeys)) + successfulRestore.addAll(ctx.restoreMap(data._Int, source, restoreKeys)) + successfulRestore.addAll(ctx.restoreMap(data._String, source, restoreKeys)) + successfulRestore.addAll(ctx.restoreMap(data._Float, source, restoreKeys)) + successfulRestore.addAll(ctx.restoreMap(data._Long, source, restoreKeys)) + successfulRestore.addAll(ctx.restoreMap(data._StringSet, source, restoreKeys)) + + return successfulRestore + } + + private fun getData(source: RestoreSource) = when (source) { + RestoreSource.SYNC -> syncMeta + RestoreSource.DATA -> datastore + RestoreSource.SETTINGS -> settings + } + } @Suppress("UNCHECKED_CAST") fun Context.getBackup(): BackupFile { @@ -142,40 +179,38 @@ object BackupUtils { ) } + @WorkerThread + fun Context.restore(backupFile: BackupFile, restoreKeys: Set? = null) = restore( + backupFile, + restoreKeys, + RestoreSource.SYNC, + RestoreSource.DATA, + RestoreSource.SETTINGS + ) + @WorkerThread fun Context.restore( backupFile: BackupFile, - restoreKeys: List? = null, - restoreSettings: Boolean, - restoreDataStore: Boolean, - restoreSyncData: Boolean + restoreKeys: Set? = null, + vararg restoreSources: RestoreSource ) { - if (restoreSyncData) { - restoreMap(backupFile.syncMeta._Bool, RestoreSource.SYNC, restoreKeys) - restoreMap(backupFile.syncMeta._Int, RestoreSource.SYNC, restoreKeys) - restoreMap(backupFile.syncMeta._String, RestoreSource.SYNC, restoreKeys) - restoreMap(backupFile.syncMeta._Float, RestoreSource.SYNC, restoreKeys) - restoreMap(backupFile.syncMeta._Long, RestoreSource.SYNC, restoreKeys) - restoreMap(backupFile.syncMeta._StringSet, RestoreSource.SYNC, restoreKeys) + for (restoreSource in restoreSources) { + val restoreData = RestoreMapData() + + restoreData.addAll(backupFile.restore(this, restoreSource, restoreKeys)) + + // we must remove keys that are not present + if (!restoreKeys.isNullOrEmpty()) { + Log.d(BackupAPI.LOG_KEY, "successfulRestore for src=[${restoreSource.name}]: ${restoreData.successfulRestore}") + val removedKeys = restoreData.wantToRestore - restoreData.successfulRestore + Log.d(BackupAPI.LOG_KEY, "removed keys for src=[${restoreSource.name}]: $removedKeys") + + removedKeys.forEach { removeKeyRaw(it, restoreSource) } + } } - if (restoreSettings) { - restoreMap(backupFile.settings._Bool, RestoreSource.SETTINGS, restoreKeys) - restoreMap(backupFile.settings._Int, RestoreSource.SETTINGS, restoreKeys) - restoreMap(backupFile.settings._String, RestoreSource.SETTINGS, restoreKeys) - restoreMap(backupFile.settings._Float, RestoreSource.SETTINGS, restoreKeys) - restoreMap(backupFile.settings._Long, RestoreSource.SETTINGS, restoreKeys) - restoreMap(backupFile.settings._StringSet, RestoreSource.SETTINGS, restoreKeys) - } - - if (restoreDataStore) { - restoreMap(backupFile.datastore._Bool, RestoreSource.DATA, restoreKeys) - restoreMap(backupFile.datastore._Int, RestoreSource.DATA, restoreKeys) - restoreMap(backupFile.datastore._String, RestoreSource.DATA, restoreKeys) - restoreMap(backupFile.datastore._Float, RestoreSource.DATA, restoreKeys) - restoreMap(backupFile.datastore._Long, RestoreSource.DATA, restoreKeys) - restoreMap(backupFile.datastore._StringSet, RestoreSource.DATA, restoreKeys) - } + Log.d(BackupAPI.LOG_KEY, "restore on ui event fired") + afterBackupRestoreEvent.invoke(Unit) } @SuppressLint("SimpleDateFormat") @@ -263,12 +298,7 @@ object BackupUtils { val input = activity.contentResolver.openInputStream(uri) ?: return@ioSafe - activity.restore( - mapper.readValue(input), - restoreSettings = true, - restoreDataStore = true, - restoreSyncData = true - ) + activity.restore(mapper.readValue(input)) activity.runOnUiThread { activity.recreate() } } catch (e: Exception) { logError(e) @@ -310,10 +340,10 @@ object BackupUtils { private fun Context.restoreMap( map: Map?, restoreSource: RestoreSource, - restoreKeys: List? - ) { - val restoreOnlyThese = mutableListOf() - val successfulRestore = mutableListOf() + restoreKeys: Set? = null + ): RestoreMapData { + val restoreOnlyThese = mutableSetOf() + val successfulRestore = mutableSetOf() if (!restoreKeys.isNullOrEmpty()) { val prefixToMatch = "${BackupAPI.SYNC_HISTORY_PREFIX}${restoreSource.prefix}" @@ -327,26 +357,26 @@ object BackupUtils { restoreOnlyThese.addAll(restore) } + map?.filter { var isTransferable = it.key.isTransferable() - var canRestore = isTransferable - if (restoreOnlyThese.isNotEmpty()) { - canRestore = canRestore && restoreOnlyThese.contains(it.key) + + if (isTransferable && restoreOnlyThese.isNotEmpty()) { + isTransferable = restoreOnlyThese.contains(it.key) } - if (isTransferable && canRestore) { + if (isTransferable) { successfulRestore.add(it.key) } - canRestore + isTransferable }?.forEach { setKeyRaw(it.key, it.value, restoreSource) } - // we must remove keys that are not present - if (!restoreKeys.isNullOrEmpty()) { - var removedKeys = restoreOnlyThese - successfulRestore.toSet() - removedKeys.forEach { removeKeyRaw(it, restoreSource) } - } + return RestoreMapData( + restoreOnlyThese, + successfulRestore + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index 0edaa707..37eb830a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -27,7 +27,7 @@ object DataStore { .configure(DeserializationFeature.USE_LONG_FOR_INTS, true) .build() - private val backupScheduler = BackupAPI.createBackupScheduler() + private val backupScheduler = Scheduler.createBackupScheduler() private fun getPreferences(context: Context): SharedPreferences { return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) @@ -110,8 +110,11 @@ object DataStore { editor.remove(path) editor.apply() - getSyncPrefs().logHistoryChanged(path, BackupUtils.RestoreSource.DATA) - backupScheduler.work(BackupAPI.PreferencesSchedulerData(path, false)) + val success = + backupScheduler.work(BackupAPI.PreferencesSchedulerData(prefs, path, false)) + if (success) { + getSyncPrefs().logHistoryChanged(path, BackupUtils.RestoreSource.DATA) + } } } catch (e: Exception) { logError(e) @@ -128,12 +131,15 @@ object DataStore { fun Context.setKey(path: String, value: T) { try { - val editor: SharedPreferences.Editor = getSharedPrefs().edit() + val prefs = getSharedPrefs() + val editor: SharedPreferences.Editor = prefs.edit() editor.putString(path, mapper.writeValueAsString(value)) editor.apply() - getSyncPrefs().logHistoryChanged(path, BackupUtils.RestoreSource.DATA) - backupScheduler.work(BackupAPI.PreferencesSchedulerData(path, false)) + val success = backupScheduler.work(BackupAPI.PreferencesSchedulerData(prefs,path, false)) + if (success) { + getSyncPrefs().logHistoryChanged(path, BackupUtils.RestoreSource.DATA) + } } catch (e: Exception) { logError(e) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index 8a588d53..dcb2be5b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -15,7 +15,7 @@ import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.Coroutines.ioSafe diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt new file mode 100644 index 00000000..c62363a2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt @@ -0,0 +1,127 @@ +package com.lagradost.cloudstream3.utils + +import android.content.SharedPreferences +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.BackupAPI +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.logHistoryChanged +import com.lagradost.cloudstream3.ui.player.PLAYBACK_SPEED_KEY +import com.lagradost.cloudstream3.ui.player.RESIZE_MODE_KEY + +class Scheduler( + private val throttleTimeMs: Long, + private val onWork: (INPUT?) -> Unit, + private val canWork: ((INPUT?) -> Boolean)? = null +) { + companion object { + var SCHEDULER_ID = 1 + + fun createBackupScheduler() = Scheduler( + BackupAPI.UPLOAD_THROTTLE.inWholeMilliseconds, + onWork = { input -> + if (input == null) { + throw IllegalStateException() + } + + AccountManager.BackupApis.forEach { + it.addToQueue( + input.storeKey, + input.isSettings + ) + } + }, + canWork = { input -> + if (input == null) { + throw IllegalStateException() + } + + val invalidKeys = listOf( + VideoDownloadManager.KEY_DOWNLOAD_INFO, + PLAYBACK_SPEED_KEY, + RESIZE_MODE_KEY + ) + + return@Scheduler !invalidKeys.contains(input.storeKey) + } + ) + + // Common usage is `val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().self` + // which means it is mostly used for settings preferences, therefore we use `isSettings: Boolean = true`, be careful + // if you need to directly access `context.getSharedPreferences` (without using DataStore) and dont forget to turn it off + fun SharedPreferences.attachBackupListener( + isSettings: Boolean = true, + syncPrefs: SharedPreferences + ): BackupAPI.SharedPreferencesWithListener { + val scheduler = createBackupScheduler() + registerOnSharedPreferenceChangeListener { _, storeKey -> + val success = + scheduler.work( + BackupAPI.PreferencesSchedulerData( + syncPrefs, + storeKey, + isSettings + ) + ) + + if (success) { + syncPrefs.logHistoryChanged(storeKey, BackupUtils.RestoreSource.SETTINGS) + } + } + + return BackupAPI.SharedPreferencesWithListener(this, scheduler) + } + + fun SharedPreferences.attachBackupListener(syncPrefs: SharedPreferences): BackupAPI.SharedPreferencesWithListener { + return attachBackupListener(true, syncPrefs) + } + } + + private val id = SCHEDULER_ID++ + private val handler = Handler(Looper.getMainLooper()) + private var runnable: Runnable? = null + + fun work(input: INPUT? = null): Boolean { + if (canWork?.invoke(input) == false) { + // Log.d(LOG_KEY, "[$id] cannot schedule [${input}]") + return false + } + + Log.d(BackupAPI.LOG_KEY, "[$id] wants to schedule [${input}]") + throttle(input) + + return true + } + + fun workNow(input: INPUT? = null): Boolean { + if (canWork?.invoke(input) == false) { + Log.d(BackupAPI.LOG_KEY, "[$id] cannot run immediate [${input}]") + return false + } + + + Log.d(BackupAPI.LOG_KEY, "[$id] runs immediate [${input}]") + stop() + onWork(input) + + return true + } + + fun stop() { + runnable?.let { + handler.removeCallbacks(it) + runnable = null + } + } + + private fun throttle(input: INPUT?) { + stop() + + runnable = Runnable { + Log.d(BackupAPI.LOG_KEY, "[$id] schedule success") + onWork(input) + } + handler.postDelayed(runnable!!, throttleTimeMs) + } +} \ No newline at end of file From 7145befb55d22aad7269ca47820b76e8e11dfb2b Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Wed, 3 May 2023 22:35:10 +0200 Subject: [PATCH 10/37] feat: add remote sync capability - schedule updates only when value was really changed (true -> true is not a change) --- .../cloudstream3/syncproviders/BackupAPI.kt | 10 ++-- .../syncproviders/providers/GoogleDriveApi.kt | 6 +- .../lagradost/cloudstream3/utils/DataStore.kt | 35 +++++++---- .../lagradost/cloudstream3/utils/Scheduler.kt | 58 ++++++++++++------- 4 files changed, 69 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt index f27d43a9..c391e059 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -25,15 +25,17 @@ interface BackupAPI { val result: JSONCompareResult? ) - data class PreferencesSchedulerData( - val prefs: SharedPreferences, + data class PreferencesSchedulerData( + val syncPrefs: SharedPreferences, val storeKey: String, - val isSettings: Boolean + val oldValue: T, + val newValue: T, + val source: BackupUtils.RestoreSource ) data class SharedPreferencesWithListener( val self: SharedPreferences, - val scheduler: Scheduler + val scheduler: Scheduler> ) companion object { diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index 1a18b9a7..66287a72 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -39,16 +39,16 @@ import java.util.Date * * | State | Priority | Description * |---------:|:--------:|--------------------------------------------------------------------- - * | Progress | 1 | Check if data was really changed when calling backupscheduler.work then - * | | | dont update sync meta if not needed + * | Progress | 4 | Implement backup before user quits application * | Waiting | 2 | Add button to manually trigger sync * | Waiting | 3 | Move "https://chiff.github.io/cloudstream-sync/google-drive" - * | Waiting | 4 | Implement backup before user quits application * | Waiting | 5 | Choose what should be synced and recheck `invalidKeys` in createBackupScheduler * | Someday | 3 | Add option to use proper OAuth through Google Services One Tap * | Someday | 5 | Encrypt data on Drive (low priority) * | Solved | 1 | Racing conditions when multiple devices in use * | Solved | 2 | Restoring backup should update view models + * | Solved | 1 | Check if data was really changed when calling backupscheduler.work then + * | | | dont update sync meta if not needed */ class GoogleDriveApi(index: Int) : InAppOAuth2APIManager(index), diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index 37eb830a..71fcc484 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -8,7 +8,6 @@ import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.module.kotlin.KotlinModule import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.BackupAPI -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.logHistoryChanged const val DOWNLOAD_HEADER_CACHE = "download_header_cache" @@ -106,15 +105,21 @@ object DataStore { try { val prefs = getSharedPrefs() if (prefs.contains(path)) { + val oldValueExists = prefs.getString(path, null) != null + val editor: SharedPreferences.Editor = prefs.edit() editor.remove(path) editor.apply() - val success = - backupScheduler.work(BackupAPI.PreferencesSchedulerData(prefs, path, false)) - if (success) { - getSyncPrefs().logHistoryChanged(path, BackupUtils.RestoreSource.DATA) - } + backupScheduler.work( + BackupAPI.PreferencesSchedulerData( + prefs, + path, + oldValueExists, + false, + BackupUtils.RestoreSource.DATA + ) + ) } } catch (e: Exception) { logError(e) @@ -132,14 +137,22 @@ object DataStore { fun Context.setKey(path: String, value: T) { try { val prefs = getSharedPrefs() + val oldValue = prefs.getString(path, null) + val newValue = mapper.writeValueAsString(value) + val editor: SharedPreferences.Editor = prefs.edit() - editor.putString(path, mapper.writeValueAsString(value)) + editor.putString(path, newValue) editor.apply() - val success = backupScheduler.work(BackupAPI.PreferencesSchedulerData(prefs,path, false)) - if (success) { - getSyncPrefs().logHistoryChanged(path, BackupUtils.RestoreSource.DATA) - } + backupScheduler.work( + BackupAPI.PreferencesSchedulerData( + prefs, + path, + oldValue, + newValue, + BackupUtils.RestoreSource.DATA + ) + ) } catch (e: Exception) { logError(e) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt index c62363a2..d37581cb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt @@ -7,6 +7,7 @@ import android.util.Log import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.BackupAPI import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.logHistoryChanged +import com.lagradost.cloudstream3.ui.home.HOME_BOOKMARK_VALUE_LIST import com.lagradost.cloudstream3.ui.player.PLAYBACK_SPEED_KEY import com.lagradost.cloudstream3.ui.player.RESIZE_MODE_KEY @@ -18,17 +19,26 @@ class Scheduler( companion object { var SCHEDULER_ID = 1 - fun createBackupScheduler() = Scheduler( + private val invalidSchedulerKeys = listOf( + VideoDownloadManager.KEY_DOWNLOAD_INFO, + PLAYBACK_SPEED_KEY, + HOME_BOOKMARK_VALUE_LIST, + RESIZE_MODE_KEY + ) + + fun createBackupScheduler() = Scheduler>( BackupAPI.UPLOAD_THROTTLE.inWholeMilliseconds, onWork = { input -> if (input == null) { throw IllegalStateException() } + input.syncPrefs.logHistoryChanged(input.storeKey, input.source) + AccountManager.BackupApis.forEach { it.addToQueue( input.storeKey, - input.isSettings + input.source == BackupUtils.RestoreSource.SETTINGS ) } }, @@ -37,13 +47,17 @@ class Scheduler( throw IllegalStateException() } - val invalidKeys = listOf( - VideoDownloadManager.KEY_DOWNLOAD_INFO, - PLAYBACK_SPEED_KEY, - RESIZE_MODE_KEY - ) + val hasInvalidKey = invalidSchedulerKeys.contains(input.storeKey) + if (hasInvalidKey) { + return@Scheduler false + } - return@Scheduler !invalidKeys.contains(input.storeKey) + val valueDidNotChange = input.oldValue == input.newValue + if (valueDidNotChange) { + return@Scheduler false + } + + return@Scheduler true } ) @@ -51,30 +65,30 @@ class Scheduler( // which means it is mostly used for settings preferences, therefore we use `isSettings: Boolean = true`, be careful // if you need to directly access `context.getSharedPreferences` (without using DataStore) and dont forget to turn it off fun SharedPreferences.attachBackupListener( - isSettings: Boolean = true, + source: BackupUtils.RestoreSource = BackupUtils.RestoreSource.SETTINGS, syncPrefs: SharedPreferences ): BackupAPI.SharedPreferencesWithListener { val scheduler = createBackupScheduler() - registerOnSharedPreferenceChangeListener { _, storeKey -> - val success = - scheduler.work( - BackupAPI.PreferencesSchedulerData( - syncPrefs, - storeKey, - isSettings - ) - ) - if (success) { - syncPrefs.logHistoryChanged(storeKey, BackupUtils.RestoreSource.SETTINGS) - } + var lastValue = all + registerOnSharedPreferenceChangeListener { sharedPreferences, storeKey -> + scheduler.work( + BackupAPI.PreferencesSchedulerData( + syncPrefs, + storeKey, + lastValue[storeKey], + sharedPreferences.all[storeKey], + source + ) + ) + lastValue = sharedPreferences.all } return BackupAPI.SharedPreferencesWithListener(this, scheduler) } fun SharedPreferences.attachBackupListener(syncPrefs: SharedPreferences): BackupAPI.SharedPreferencesWithListener { - return attachBackupListener(true, syncPrefs) + return attachBackupListener(BackupUtils.RestoreSource.SETTINGS, syncPrefs) } } From 842ac5fbe0f77faab7f7b91e976708149447eb22 Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Mon, 8 May 2023 14:52:13 +0200 Subject: [PATCH 11/37] feat: add remote sync capability - run upload on activity destroy --- app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt | 3 +++ .../com/lagradost/cloudstream3/syncproviders/BackupAPI.kt | 4 +++- .../cloudstream3/syncproviders/providers/GoogleDriveApi.kt | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 4dc939d6..2bf843f1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -55,6 +55,7 @@ import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager.loadAllOnlinePlugins import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.BackupApis import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString @@ -612,6 +613,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java) this.sendBroadcast(broadcastIntent) afterPluginsLoadedEvent -= ::onAllPluginsLoaded + // run sync before app quits + BackupApis.forEach { it.addToQueueNow() } super.onDestroy() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt index c391e059..72feefae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -102,12 +102,14 @@ interface BackupAPI { return } + addToQueueNow() + } + fun addToQueueNow() { if (uploadJob != null && uploadJob!!.isActive) { Log.d(LOG_KEY, "upload is canceled, scheduling new") uploadJob?.cancel() } - // we should ensure job will before app is closed uploadJob = ioScope.launchSafe { Log.d(LOG_KEY, "upload is running now") uploadSyncData() diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index 66287a72..b53284db 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -39,7 +39,7 @@ import java.util.Date * * | State | Priority | Description * |---------:|:--------:|--------------------------------------------------------------------- - * | Progress | 4 | Implement backup before user quits application + * | Progress | 1 | Do not write sync meta when user is not syncing data * | Waiting | 2 | Add button to manually trigger sync * | Waiting | 3 | Move "https://chiff.github.io/cloudstream-sync/google-drive" * | Waiting | 5 | Choose what should be synced and recheck `invalidKeys` in createBackupScheduler @@ -49,6 +49,7 @@ import java.util.Date * | Solved | 2 | Restoring backup should update view models * | Solved | 1 | Check if data was really changed when calling backupscheduler.work then * | | | dont update sync meta if not needed + * | Solved | 4 | Implement backup before user quits application */ class GoogleDriveApi(index: Int) : InAppOAuth2APIManager(index), From 13978f82031c43d5d0d7d2932c5040c194eaaf50 Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Mon, 8 May 2023 15:25:09 +0200 Subject: [PATCH 12/37] feat: add remote sync capability - update sync meta only if backup api is active --- .../cloudstream3/syncproviders/BackupAPI.kt | 11 +++++++++++ .../syncproviders/providers/GoogleDriveApi.kt | 18 +++++++++++++++--- .../lagradost/cloudstream3/utils/Scheduler.kt | 5 +++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt index 72feefae..99cd60ae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -57,6 +57,17 @@ interface BackupAPI { } } + /** + * isActive is recommended to be overridden to verifiy if BackupApi is being used. if manager + * is not set up it won't write sync data. + * @see Scheduler.Companion.createBackupScheduler + * @see SharedPreferences.logHistoryChanged + */ + var isActive: Boolean? + fun updateApiActiveState() { + this.isActive = this.isActive() + } + fun isActive(): Boolean /** * Should download data from API and call Context.mergeBackup(incomingData: String). If data * does not exist on the api uploadSyncData() is recommended to call. Should be called with diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index b53284db..31f6a07c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -39,7 +39,6 @@ import java.util.Date * * | State | Priority | Description * |---------:|:--------:|--------------------------------------------------------------------- - * | Progress | 1 | Do not write sync meta when user is not syncing data * | Waiting | 2 | Add button to manually trigger sync * | Waiting | 3 | Move "https://chiff.github.io/cloudstream-sync/google-drive" * | Waiting | 5 | Choose what should be synced and recheck `invalidKeys` in createBackupScheduler @@ -50,6 +49,7 @@ import java.util.Date * | Solved | 1 | Check if data was really changed when calling backupscheduler.work then * | | | dont update sync meta if not needed * | Solved | 4 | Implement backup before user quits application + * | Solved | 1 | Do not write sync meta when user is not syncing data */ class GoogleDriveApi(index: Int) : InAppOAuth2APIManager(index), @@ -70,6 +70,7 @@ class GoogleDriveApi(index: Int) : override val defaultFilenameValue = "cloudstreamapp-sync-file" override val defaultRedirectUrl = "https://chiff.github.io/cloudstream-sync/google-drive" + override var isActive: Boolean? = false override var uploadJob: Job? = null private var tempAuthFlow: AuthorizationCodeFlow? = null @@ -109,9 +110,10 @@ class GoogleDriveApi(index: Int) : ) storeValue(K.TOKEN, googleTokenResponse) + storeValue(K.IS_READY, true) + updateApiActiveState() runDownloader(runNow = true, overwrite = true) - storeValue(K.IS_READY, true) tempAuthFlow = null return true } @@ -120,7 +122,8 @@ class GoogleDriveApi(index: Int) : ///////////////////////////////////////// // InAppOAuth2APIManager implementation override suspend fun initialize() { - if (loginInfo() == null) { + updateApiActiveState() + if (isActive != true) { return } @@ -157,6 +160,7 @@ class GoogleDriveApi(index: Int) : this.tempAuthFlow = authFlow try { + updateApiActiveState() registerAccount() val url = authFlow.newAuthorizationUrl().setRedirectUri(data.redirectUrl).build() @@ -178,6 +182,14 @@ class GoogleDriveApi(index: Int) : ///////////////////////////////////////// ///////////////////////////////////////// // BackupAPI implementation + override fun isActive(): Boolean { + return getValue(K.IS_READY) == true && + loginInfo() != null && + getDriveService() != null && + AcraApplication.context != null && + getLatestLoginData() != null + } + override fun Context.createBackup(loginData: InAppOAuth2API.LoginData) { val drive = getDriveService() ?: return diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt index d37581cb..c33b30d0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt @@ -47,6 +47,11 @@ class Scheduler( throw IllegalStateException() } + val hasSomeActiveManagers = AccountManager.BackupApis.any { it.isActive == true } + if (!hasSomeActiveManagers) { + return@Scheduler false + } + val hasInvalidKey = invalidSchedulerKeys.contains(input.storeKey) if (hasInvalidKey) { return@Scheduler false From de3019b1d84d8bce6d9e717021fe8c18cf7cdd99 Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Mon, 8 May 2023 15:43:52 +0200 Subject: [PATCH 13/37] feat: add remote sync capability - fix file exists check --- .../syncproviders/providers/GoogleDriveApi.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index 31f6a07c..fb161b3c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -204,7 +204,7 @@ class GoogleDriveApi(index: Int) : fileMetadata.mimeType = "application/json" val fileContent = FileContent("application/json", ioFile) - val fileId = getOrCreateSyncFileId(drive, loginData) + val fileId = getOrFindExistingSyncFileId(drive, loginData) if (fileId != null) { try { val file = drive.files() @@ -232,7 +232,7 @@ class GoogleDriveApi(index: Int) : val drive = getDriveService() ?: return val loginData = getLatestLoginData() ?: return - val existingFileId = getOrCreateSyncFileId(drive, loginData) + val existingFileId = getOrFindExistingSyncFileId(drive, loginData) val existingFile = if (existingFileId != null) { try { drive.files().get(existingFileId) @@ -261,11 +261,15 @@ class GoogleDriveApi(index: Int) : uploadSyncData() } - private fun getOrCreateSyncFileId(drive: Drive, loginData: InAppOAuth2API.LoginData): String? { + private fun getOrFindExistingSyncFileId( + drive: Drive, + loginData: InAppOAuth2API.LoginData + ): String? { if (loginData.syncFileId != null) { - val verified = drive.files().get(loginData.syncFileId) - if (verified != null) { - return loginData.syncFileId + try { + val verified = drive.files().get(loginData.syncFileId).execute() + return verified.id + } catch (_: Exception) { } } From dee51d8695affc1f6ff32073224db791491f2935 Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Mon, 8 May 2023 19:35:24 +0200 Subject: [PATCH 14/37] feat: add remote sync capability - fix sync/restore logic --- .../cloudstream3/syncproviders/BackupAPI.kt | 55 ++++++++++++------- .../syncproviders/providers/GoogleDriveApi.kt | 3 + .../cloudstream3/utils/BackupUtils.kt | 31 ++++++++--- .../lagradost/cloudstream3/utils/DataStore.kt | 4 +- .../lagradost/cloudstream3/utils/Scheduler.kt | 11 ++-- 5 files changed, 69 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt index 99cd60ae..afe77d04 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -52,7 +52,7 @@ interface BackupAPI { private val ioScope = CoroutineScope(Dispatchers.IO) fun SharedPreferences.logHistoryChanged(path: String, source: BackupUtils.RestoreSource) { - edit().putLong("$SYNC_HISTORY_PREFIX${source.prefix}$path", System.currentTimeMillis()) + edit().putLong("${source.syncPrefix}$path", System.currentTimeMillis()) .apply() } } @@ -132,7 +132,7 @@ interface BackupAPI { val executionTime = measureTimeMillis { result = try { - JSONCompare.compareJSON(old, new, JSONCompareMode.LENIENT) + JSONCompare.compareJSON(old, new, JSONCompareMode.NON_EXTENSIBLE) } catch (e: Exception) { null } @@ -157,7 +157,7 @@ interface BackupAPI { }.keys val onlyLocalKeys = currentSync.keys.filter { !newSync.containsKey(it) } - val missingKeys = getMissingKeys(currentData, newData) + val missingKeys = getAllMissingKeys(currentData, newData) return (missingKeys + onlyLocalKeys + changedKeys).toSet() } @@ -166,26 +166,41 @@ interface BackupAPI { data.syncMeta._Long.orEmpty().filter { it.key.startsWith(SYNC_HISTORY_PREFIX) } - // 🤮 - private fun getMissingKeys( + private fun getAllMissingKeys( old: BackupUtils.BackupFile, new: BackupUtils.BackupFile - ): List = mutableListOf( - *getMissing(old.settings._Bool, new.settings._Bool), - *getMissing(old.settings._Long, new.settings._Long), - *getMissing(old.settings._Float, new.settings._Float), - *getMissing(old.settings._Int, new.settings._Int), - *getMissing(old.settings._String, new.settings._String), - *getMissing(old.settings._StringSet, new.settings._StringSet), - *getMissing(old.datastore._Bool, new.datastore._Bool), - *getMissing(old.datastore._Long, new.datastore._Long), - *getMissing(old.datastore._Float, new.datastore._Float), - *getMissing(old.datastore._Int, new.datastore._Int), - *getMissing(old.datastore._String, new.datastore._String), - *getMissing(old.datastore._StringSet, new.datastore._StringSet), - ) + ): List = BackupUtils.RestoreSource + .values() + .filter { it != BackupUtils.RestoreSource.SYNC } + .fold(mutableListOf()) { acc, source -> + acc.addAll(getMissingKeysPrefixed(source, old, new)) + acc + } + + private fun getMissingKeysPrefixed( + restoreSource: BackupUtils.RestoreSource, + old: BackupUtils.BackupFile, + new: BackupUtils.BackupFile + ): List { + val oldSource = old.getData(restoreSource) + val newSource = new.getData(restoreSource) + val prefixToMatch = restoreSource.syncPrefix + + return listOf( + *getMissing(oldSource._Bool, newSource._Bool), + *getMissing(oldSource._Long, newSource._Long), + *getMissing(oldSource._Float, newSource._Float), + *getMissing(oldSource._Int, newSource._Int), + *getMissing(oldSource._String, newSource._String), + *getMissing(oldSource._StringSet, newSource._StringSet), + ).map { + prefixToMatch + it + } + } + private fun getMissing(old: Map?, new: Map?): Array = - new.orEmpty().keys.subtract(old.orEmpty().keys).toTypedArray() + (new.orEmpty().keys - old.orEmpty().keys) + .toTypedArray() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index fb161b3c..f3d51d89 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -39,6 +39,8 @@ import java.util.Date * * | State | Priority | Description * |---------:|:--------:|--------------------------------------------------------------------- + * | Progress | 1 | When scheduler has queued upload job (but is not working in backupApi + * | | | yet) we should postpone download and prioritize upload * | Waiting | 2 | Add button to manually trigger sync * | Waiting | 3 | Move "https://chiff.github.io/cloudstream-sync/google-drive" * | Waiting | 5 | Choose what should be synced and recheck `invalidKeys` in createBackupScheduler @@ -50,6 +52,7 @@ import java.util.Date * | | | dont update sync meta if not needed * | Solved | 4 | Implement backup before user quits application * | Solved | 1 | Do not write sync meta when user is not syncing data + * | Solved | 1 | Fix sync/restore bugs */ class GoogleDriveApi(index: Int) : InAppOAuth2APIManager(index), 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 2ef3e2da..27c110d3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -55,12 +55,13 @@ object BackupUtils { DATA, SETTINGS, SYNC; val prefix = "$name/" + val syncPrefix = "${BackupAPI.SYNC_HISTORY_PREFIX}$prefix" } /** * No sensitive or breaking data in the backup * */ - private val nonTransferableKeys = listOf( + val nonTransferableKeys = listOf( // When sharing backup we do not want to transfer what is essentially the password ANILIST_TOKEN_KEY, ANILIST_CACHED_LIST, @@ -132,7 +133,7 @@ object BackupUtils { return successfulRestore } - private fun getData(source: RestoreSource) = when (source) { + fun getData(source: RestoreSource) = when (source) { RestoreSource.SYNC -> syncMeta RestoreSource.DATA -> datastore RestoreSource.SETTINGS -> settings @@ -194,6 +195,8 @@ object BackupUtils { restoreKeys: Set? = null, vararg restoreSources: RestoreSource ) { + Log.d(BackupAPI.LOG_KEY, "will restore keys = $restoreKeys") + for (restoreSource in restoreSources) { val restoreData = RestoreMapData() @@ -346,12 +349,18 @@ object BackupUtils { val successfulRestore = mutableSetOf() if (!restoreKeys.isNullOrEmpty()) { - val prefixToMatch = "${BackupAPI.SYNC_HISTORY_PREFIX}${restoreSource.prefix}" + var prefixToMatch = restoreSource.syncPrefix + var prefixToRemove = prefixToMatch + + if (restoreSource == RestoreSource.SYNC) { + prefixToMatch = BackupAPI.SYNC_HISTORY_PREFIX + prefixToRemove = "" + } val restore = restoreKeys.filter { it.startsWith(prefixToMatch) }.map { - it.removePrefix(prefixToMatch) + it.removePrefix(prefixToRemove) } restoreOnlyThese.addAll(restore) @@ -359,19 +368,19 @@ object BackupUtils { map?.filter { - var isTransferable = it.key.isTransferable() + var isTransferable = it.key.withoutPrefix(restoreSource).isTransferable() if (isTransferable && restoreOnlyThese.isNotEmpty()) { - isTransferable = restoreOnlyThese.contains(it.key) + isTransferable = restoreOnlyThese.contains(it.key.withoutPrefix(restoreSource)) } if (isTransferable) { - successfulRestore.add(it.key) + successfulRestore.add(it.key.withoutPrefix(restoreSource)) } isTransferable }?.forEach { - setKeyRaw(it.key, it.value, restoreSource) + setKeyRaw(it.key.withoutPrefix(restoreSource), it.value, restoreSource) } return RestoreMapData( @@ -379,4 +388,8 @@ object BackupUtils { successfulRestore ) } -} \ No newline at end of file +} + +private fun String.withoutPrefix(restoreSource: BackupUtils.RestoreSource) = + // will not remove sync prefix because it wont match (its not a bug its a feature ¯\_(ツ)_/¯ ) + removePrefix(restoreSource.prefix) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index 71fcc484..096f76b4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -113,7 +113,7 @@ object DataStore { backupScheduler.work( BackupAPI.PreferencesSchedulerData( - prefs, + getSyncPrefs(), path, oldValueExists, false, @@ -146,7 +146,7 @@ object DataStore { backupScheduler.work( BackupAPI.PreferencesSchedulerData( - prefs, + getSyncPrefs(), path, oldValue, newValue, diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt index c33b30d0..ee15b8f3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt @@ -10,6 +10,7 @@ import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.logHistoryCh import com.lagradost.cloudstream3.ui.home.HOME_BOOKMARK_VALUE_LIST import com.lagradost.cloudstream3.ui.player.PLAYBACK_SPEED_KEY import com.lagradost.cloudstream3.ui.player.RESIZE_MODE_KEY +import com.lagradost.cloudstream3.utils.BackupUtils.nonTransferableKeys class Scheduler( private val throttleTimeMs: Long, @@ -19,8 +20,11 @@ class Scheduler( companion object { var SCHEDULER_ID = 1 - private val invalidSchedulerKeys = listOf( + // these will not run upload scheduler, however only `nonTransferableKeys` are not stored + private val invalidUploadTriggerKeys = listOf( + *nonTransferableKeys.toTypedArray(), VideoDownloadManager.KEY_DOWNLOAD_INFO, + DOWNLOAD_HEADER_CACHE, PLAYBACK_SPEED_KEY, HOME_BOOKMARK_VALUE_LIST, RESIZE_MODE_KEY @@ -33,8 +37,6 @@ class Scheduler( throw IllegalStateException() } - input.syncPrefs.logHistoryChanged(input.storeKey, input.source) - AccountManager.BackupApis.forEach { it.addToQueue( input.storeKey, @@ -52,7 +54,7 @@ class Scheduler( return@Scheduler false } - val hasInvalidKey = invalidSchedulerKeys.contains(input.storeKey) + val hasInvalidKey = invalidUploadTriggerKeys.contains(input.storeKey) if (hasInvalidKey) { return@Scheduler false } @@ -62,6 +64,7 @@ class Scheduler( return@Scheduler false } + input.syncPrefs.logHistoryChanged(input.storeKey, input.source) return@Scheduler true } ) From 0405cdca7f439b3717df3d57d3358b030180cd02 Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Mon, 8 May 2023 19:45:34 +0200 Subject: [PATCH 15/37] feat: add remote sync capability - downloader will now wait when upload scheduler is throttling job --- .../cloudstream3/syncproviders/BackupAPI.kt | 4 ++++ .../syncproviders/providers/GoogleDriveApi.kt | 16 +++++++++------- .../lagradost/cloudstream3/utils/Scheduler.kt | 10 ++++++++++ 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt index afe77d04..66acb5f4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -105,10 +105,13 @@ interface BackupAPI { restore(newData, keysToUpdate) } + var willQueueSoon: Boolean? var uploadJob: Job? fun shouldUpdate(changedKey: String, isSettings: Boolean): Boolean fun addToQueue(changedKey: String, isSettings: Boolean) { + if (!shouldUpdate(changedKey, isSettings)) { + willQueueSoon = false Log.d(LOG_KEY, "upload not required, data is same") return } @@ -122,6 +125,7 @@ interface BackupAPI { } uploadJob = ioScope.launchSafe { + willQueueSoon = false Log.d(LOG_KEY, "upload is running now") uploadSyncData() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index f3d51d89..17801a25 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -39,11 +39,9 @@ import java.util.Date * * | State | Priority | Description * |---------:|:--------:|--------------------------------------------------------------------- - * | Progress | 1 | When scheduler has queued upload job (but is not working in backupApi - * | | | yet) we should postpone download and prioritize upload - * | Waiting | 2 | Add button to manually trigger sync * | Waiting | 3 | Move "https://chiff.github.io/cloudstream-sync/google-drive" - * | Waiting | 5 | Choose what should be synced and recheck `invalidKeys` in createBackupScheduler + * | Someday | 4 | Add button to manually trigger sync + * | Someday | 5 | Choose what should be synced and recheck `invalidKeys` in createBackupScheduler * | Someday | 3 | Add option to use proper OAuth through Google Services One Tap * | Someday | 5 | Encrypt data on Drive (low priority) * | Solved | 1 | Racing conditions when multiple devices in use @@ -53,6 +51,8 @@ import java.util.Date * | Solved | 4 | Implement backup before user quits application * | Solved | 1 | Do not write sync meta when user is not syncing data * | Solved | 1 | Fix sync/restore bugs + * | Solved | 1 | When scheduler has queued upload job (but is not working in backupApi + * | | | yet) we should postpone download and prioritize upload */ class GoogleDriveApi(index: Int) : InAppOAuth2APIManager(index), @@ -74,6 +74,7 @@ class GoogleDriveApi(index: Int) : override val defaultRedirectUrl = "https://chiff.github.io/cloudstream-sync/google-drive" override var isActive: Boolean? = false + override var willQueueSoon: Boolean? = false override var uploadJob: Job? = null private var tempAuthFlow: AuthorizationCodeFlow? = null @@ -344,8 +345,8 @@ class GoogleDriveApi(index: Int) : // Internal private val continuousDownloader = Scheduler( BackupAPI.DOWNLOAD_THROTTLE.inWholeMilliseconds, - { overwrite -> - if (uploadJob?.isActive == true) { + onWork = { overwrite -> + if (uploadJob?.isActive == true || willQueueSoon == true) { uploadJob!!.invokeOnCompletion { Log.d(LOG_KEY, "upload is running, reschedule download") runDownloader(false, overwrite == true) @@ -357,7 +358,8 @@ class GoogleDriveApi(index: Int) : } runDownloader() } - }) + } + ) private fun runDownloader(runNow: Boolean = false, overwrite: Boolean = false) { if (runNow) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt index ee15b8f3..26f982af 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt @@ -15,6 +15,7 @@ import com.lagradost.cloudstream3.utils.BackupUtils.nonTransferableKeys class Scheduler( private val throttleTimeMs: Long, private val onWork: (INPUT?) -> Unit, + private val beforeWork: ((INPUT?) -> Unit)? = null, private val canWork: ((INPUT?) -> Boolean)? = null ) { companion object { @@ -44,6 +45,13 @@ class Scheduler( ) } }, + beforeWork = { + AccountManager.BackupApis.filter { + it.isActive == true + }.forEach { + it.willQueueSoon = true + } + }, canWork = { input -> if (input == null) { throw IllegalStateException() @@ -111,6 +119,7 @@ class Scheduler( } Log.d(BackupAPI.LOG_KEY, "[$id] wants to schedule [${input}]") + beforeWork?.invoke(input) throttle(input) return true @@ -124,6 +133,7 @@ class Scheduler( Log.d(BackupAPI.LOG_KEY, "[$id] runs immediate [${input}]") + beforeWork?.invoke(input) stop() onWork(input) From 13d9cee89b829fff5384cdb91fb7887e064262a3 Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Mon, 8 May 2023 20:24:15 +0200 Subject: [PATCH 16/37] feat: add remote sync capability - add info button in login dialog --- .../syncproviders/InAppOAuth2API.kt | 1 + .../syncproviders/providers/GoogleDriveApi.kt | 1 + .../helpers/settings/account/DialogBuilder.kt | 10 ++++++++-- .../account/InAppOAuth2DialogBuilder.kt | 19 +++++++++++++++++-- .../res/layout/add_account_input_oauth.xml | 8 ++++++++ app/src/main/res/values/strings.xml | 1 + 6 files changed, 36 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt index 2d986d99..3788e26a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt @@ -23,6 +23,7 @@ interface InAppOAuth2API : OAuth2API { val defaultFilenameValue: String val defaultRedirectUrl: String + val infoUrl: String? // should launch intent to acquire token diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index 17801a25..1b6d52d4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -72,6 +72,7 @@ class GoogleDriveApi(index: Int) : override val requiresClientId = true override val defaultFilenameValue = "cloudstreamapp-sync-file" override val defaultRedirectUrl = "https://chiff.github.io/cloudstream-sync/google-drive" + override val infoUrl = "https://chiff.github.io/cloudstream-sync/google-drive/help.html" override var isActive: Boolean? = false override var willQueueSoon: Boolean? = false diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt index 741e2817..c493a104 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt @@ -23,13 +23,13 @@ abstract class DialogBuilder( private val btnApply: MaterialButton, private val btnCancel: MaterialButton, private val btnAccCreate: MaterialButton?, - private val btnConfirmOauth: MaterialButton? + private val btnInfo: MaterialButton? ) { fun getTitle() = dialog.getCommonItem(title)!! fun getBtnApply() = dialog.getCommonItem(btnApply)!! fun getBtnCancel() = dialog.getCommonItem(btnCancel)!! fun getBtnAccCreate() = dialog.getCommonItem(btnAccCreate) - fun getBtnConfirm() = dialog.getCommonItem(btnConfirmOauth) + fun getBtnInfo() = dialog.getCommonItem(btnInfo) private fun AlertDialog.getCommonItem(view: T?): T? { return findViewById(view?.id ?: return null) @@ -122,6 +122,12 @@ abstract class DialogBuilder( it.nextFocusUpId = createAccount.id } + displayedItems.firstOrNull()?.let { + val infoButton = getCommonItems(dialog).getBtnInfo() ?: return@let + infoButton.nextFocusDownId = it.id + it.nextFocusUpId = infoButton.id + } + getCommonItems(dialog).getBtnApply().id.let { displayedItems.lastOrNull()?.nextFocusDownId = it } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt index 1d93d682..1c15e7ea 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt @@ -1,14 +1,23 @@ package com.lagradost.cloudstream3.ui.settings.helpers.settings.account +import android.net.Uri import android.view.View import androidx.appcompat.app.AlertDialog +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import kotlinx.android.synthetic.main.add_account_input_oauth.* +import kotlinx.android.synthetic.main.add_account_input_oauth.apply_btt +import kotlinx.android.synthetic.main.add_account_input_oauth.cancel_btt +import kotlinx.android.synthetic.main.add_account_input_oauth.info_button +import kotlinx.android.synthetic.main.add_account_input_oauth.login_client_id +import kotlinx.android.synthetic.main.add_account_input_oauth.login_client_secret +import kotlinx.android.synthetic.main.add_account_input_oauth.login_file_name +import kotlinx.android.synthetic.main.add_account_input_oauth.text1 class InAppOAuth2DialogBuilder( @@ -16,7 +25,7 @@ class InAppOAuth2DialogBuilder( private val activity: FragmentActivity?, ) : DialogBuilder(api, activity, R.style.AlertDialogCustom, R.layout.add_account_input_oauth) { override fun getCommonItems(dialog: AlertDialog) = with(dialog) { - CommonDialogItems(dialog, text1, apply_btt, cancel_btt, null, null) + CommonDialogItems(dialog, text1, apply_btt, cancel_btt, null, info_button) } override fun getVisibilityMap(dialog: AlertDialog): Map = with(dialog) { @@ -31,6 +40,12 @@ class InAppOAuth2DialogBuilder( login_file_name?.isVisible = api.requiresFilename login_client_id?.isVisible = api.requiresClientId login_client_secret?.isVisible = api.requiresSecret + + info_button?.isGone = api.infoUrl.isNullOrBlank() + info_button?.setOnClickListener { + val customTabIntent = CustomTabsIntent.Builder().setShowTitle(true).build() + customTabIntent.launchUrl(context, Uri.parse(api.infoUrl)) + } } diff --git a/app/src/main/res/layout/add_account_input_oauth.xml b/app/src/main/res/layout/add_account_input_oauth.xml index efc929f2..acfd59e8 100644 --- a/app/src/main/res/layout/add_account_input_oauth.xml +++ b/app/src/main/res/layout/add_account_input_oauth.xml @@ -29,6 +29,14 @@ android:textStyle="bold" tools:text="Test" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 68325225..25a2592d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -667,4 +667,5 @@ Sync file name (optional) Oauth redirect url (optional) https://chiff.github.io/cloudstream-sync/google-drive + Info \ No newline at end of file From 2557d82965b9a48f1c37b62e034cd4619d799754 Mon Sep 17 00:00:00 2001 From: marekf2236 Date: Sat, 13 May 2023 19:28:31 +0200 Subject: [PATCH 17/37] feat: add remote sync capability - add info button in login dialog Co-Authored-By: Martin Filo <10731497+chiff@users.noreply.github.com> --- .../cloudstream3/syncproviders/providers/GoogleDriveApi.kt | 6 +++--- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index 1b6d52d4..513c79d4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -39,7 +39,6 @@ import java.util.Date * * | State | Priority | Description * |---------:|:--------:|--------------------------------------------------------------------- - * | Waiting | 3 | Move "https://chiff.github.io/cloudstream-sync/google-drive" * | Someday | 4 | Add button to manually trigger sync * | Someday | 5 | Choose what should be synced and recheck `invalidKeys` in createBackupScheduler * | Someday | 3 | Add option to use proper OAuth through Google Services One Tap @@ -53,6 +52,7 @@ import java.util.Date * | Solved | 1 | Fix sync/restore bugs * | Solved | 1 | When scheduler has queued upload job (but is not working in backupApi * | | | yet) we should postpone download and prioritize upload + * | Solved | 3 | Move "https://chiff.github.io/cloudstream-sync/google-drive" */ class GoogleDriveApi(index: Int) : InAppOAuth2APIManager(index), @@ -71,8 +71,8 @@ class GoogleDriveApi(index: Int) : override val requiresSecret = true override val requiresClientId = true override val defaultFilenameValue = "cloudstreamapp-sync-file" - override val defaultRedirectUrl = "https://chiff.github.io/cloudstream-sync/google-drive" - override val infoUrl = "https://chiff.github.io/cloudstream-sync/google-drive/help.html" + override val defaultRedirectUrl = "https://recloudstream.github.io/cloudstream-sync/google-drive" + override val infoUrl = "https://recloudstream.github.io/cloudstream-sync/google-drive/help.html" override var isActive: Boolean? = false override var willQueueSoon: Boolean? = false diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 25a2592d..1fd95e84 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -666,6 +666,6 @@ Episode %d released! Sync file name (optional) Oauth redirect url (optional) - https://chiff.github.io/cloudstream-sync/google-drive + https://recloudstream.github.io/cloudstream-sync/google-drive Info \ No newline at end of file From 4b28bf9f575307c5d475ed083dca52fe8fd8f360 Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Sun, 11 Jun 2023 18:48:19 +0200 Subject: [PATCH 18/37] fix: add remote sync capability - fix icon for flashbang theme --- .../drawable/ic_baseline_add_to_drive_24.xml | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/app/src/debug/res/drawable/ic_baseline_add_to_drive_24.xml b/app/src/debug/res/drawable/ic_baseline_add_to_drive_24.xml index 6a717cb3..030046d3 100644 --- a/app/src/debug/res/drawable/ic_baseline_add_to_drive_24.xml +++ b/app/src/debug/res/drawable/ic_baseline_add_to_drive_24.xml @@ -1,5 +1,17 @@ - - + + + + From 05705b1dec12fd3351a891067532a6507bc4fd70 Mon Sep 17 00:00:00 2001 From: Martin Filo Date: Sun, 11 Jun 2023 20:02:09 +0200 Subject: [PATCH 19/37] fix: add remote sync capability - fix icon backup import from older versions --- .../com/lagradost/cloudstream3/utils/BackupUtils.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 27c110d3..14447113 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -108,12 +108,21 @@ object BackupUtils { @JsonProperty("_Float") val _Float: Map?, @JsonProperty("_Long") val _Long: Map?, @JsonProperty("_StringSet") val _StringSet: Map?>?, - ) + ) { + constructor() : this( + mapOf(), + mapOf(), + mapOf(), + mapOf(), + mapOf(), + mapOf(), + ) + } data class BackupFile( @JsonProperty("datastore") val datastore: BackupVars, @JsonProperty("settings") val settings: BackupVars, - @JsonProperty("sync-meta") val syncMeta: BackupVars, + @JsonProperty("sync-meta") val syncMeta: BackupVars = BackupVars(), ) { fun restore( ctx: Context, From 2c0e40a233d816f55c58559dfb40308eee62fdac Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Thu, 24 Aug 2023 00:23:06 +0200 Subject: [PATCH 20/37] Lower targetSdk to get all installed packages --- app/build.gradle.kts | 2 +- app/src/main/AndroidManifest.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 178b49c2..dfd2c173 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -55,7 +55,7 @@ android { defaultConfig { applicationId = "com.lagradost.cloudstream3" minSdk = 21 - targetSdk = 33 + targetSdk = 29 versionCode = 59 versionName = "4.1.7" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 563c82f8..0e716034 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ - + Date: Mon, 4 Sep 2023 18:36:30 +0000 Subject: [PATCH 21/37] Update sdk version --- app/build.gradle.kts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 55d0f7ae..825d0c4b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,13 +50,13 @@ android { } } - compileSdk = 33 - buildToolsVersion = "30.0.3" + compileSdk = 34 + buildToolsVersion = "34.0.0" defaultConfig { applicationId = "com.lagradost.cloudstream3" minSdk = 21 - targetSdk = 29 + targetSdk = 34 versionCode = 59 versionName = "4.1.8" From 3c3ca21728a9ee094296594f8fa1c3a435a9176a Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Mon, 4 Sep 2023 18:42:33 +0000 Subject: [PATCH 22/37] Let's not be too radical --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 825d0c4b..e31de078 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,13 +50,13 @@ android { } } - compileSdk = 34 + compileSdk = 33 buildToolsVersion = "34.0.0" defaultConfig { applicationId = "com.lagradost.cloudstream3" minSdk = 21 - targetSdk = 34 + targetSdk = 33 versionCode = 59 versionName = "4.1.8" From 6e89ed9d81453db71d7797df07d18d0ddb7aaa99 Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Fri, 15 Sep 2023 16:39:00 +0200 Subject: [PATCH 23/37] Many fixes --- .../lagradost/cloudstream3/MainActivity.kt | 13 +- ...RecyclerView.kt => CustomRecyclerViews.kt} | 39 ++- .../ui/result/ResultFragmentTv.kt | 4 +- .../ui/result/ResultViewModel2.kt | 3 +- app/src/main/res/drawable/episodes_shadow.xml | 6 +- .../main/res/layout/fragment_result_tv.xml | 239 ++++++++++-------- 6 files changed, 184 insertions(+), 120 deletions(-) rename app/src/main/java/com/lagradost/cloudstream3/ui/{AutofitRecyclerView.kt => CustomRecyclerViews.kt} (79%) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index a07ae2c2..c57b6c0f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -539,6 +539,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { val isTrueTv = isTrueTvSettings() navView.menu.findItem(R.id.navigation_library)?.isVisible = !isTrueTv navRailView.menu.findItem(R.id.navigation_library)?.isVisible = !isTrueTv + + // Hide downloads on TV + navView.menu.findItem(R.id.navigation_downloads)?.isVisible = !isTrueTv + navRailView.menu.findItem(R.id.navigation_downloads)?.isVisible = !isTrueTv } } @@ -1112,16 +1116,17 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus -> // println("refocus $oldFocus -> $newFocus") try { - val r = Rect(0,0,0,0) + val r = Rect(0, 0, 0, 0) newFocus.getDrawingRect(r) val x = r.centerX() val y = r.centerY() val dx = 0 //screenWidth / 2 val dy = screenHeight / 2 - val r2 = Rect(x-dx,y-dy,x+dx,y+dy) + val r2 = Rect(x - dx, y - dy, x + dx, y + dy) newFocus.requestRectangleOnScreen(r2, false) - // TvFocus.current =TvFocus.current.copy(y=y.toFloat()) - } catch (_ : Throwable) { } + // TvFocus.current =TvFocus.current.copy(y=y.toFloat()) + } catch (_: Throwable) { + } TvFocus.updateFocusView(newFocus) /*var focus = newFocus diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt similarity index 79% rename from app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt rename to app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt index 28ced48c..1a9549e1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.ui import android.content.Context import android.util.AttributeSet import android.view.View +import androidx.core.view.children import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlin.math.abs @@ -70,8 +71,8 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : val orientation = this.orientation // fixes arabic by inverting left and right layout focus - val correctDirection = if(this.isLayoutRTL) { - when(direction) { + val correctDirection = if (this.isLayoutRTL) { + when (direction) { View.FOCUS_RIGHT -> View.FOCUS_LEFT View.FOCUS_LEFT -> View.FOCUS_RIGHT else -> direction @@ -83,12 +84,15 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : View.FOCUS_DOWN -> { return spanCount } + View.FOCUS_UP -> { return -spanCount } + View.FOCUS_RIGHT -> { return 1 } + View.FOCUS_LEFT -> { return -1 } @@ -98,12 +102,15 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : View.FOCUS_DOWN -> { return 1 } + View.FOCUS_UP -> { return -1 } + View.FOCUS_RIGHT -> { return spanCount } + View.FOCUS_LEFT -> { return -spanCount } @@ -155,4 +162,32 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att layoutManager = manager } +} + +/** + * Recyclerview wherein the max item width or height is set by the biggest view to prevent inconsistent view sizes. + */ +class MaxRecyclerView(ctx: Context, attrs: AttributeSet) : RecyclerView(ctx, attrs) { + private var biggestObserved: Int = 0 + private val orientation = LayoutManager.getProperties(context, attrs, 0, 0).orientation + private val isHorizontal = orientation == HORIZONTAL + private fun View.updateMaxSize() { + if (isHorizontal) { + this.minimumHeight = biggestObserved + } else { + this.minimumWidth = biggestObserved + } + } + + override fun onChildAttachedToWindow(child: View) { + child.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) + val observed = if (isHorizontal) child.measuredHeight else child.measuredWidth + if (observed > biggestObserved) { + biggestObserved = observed + children.forEach { it.updateMaxSize() } + } else { + child.updateMaxSize() + } + super.onChildAttachedToWindow(child) + } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt index be3de52b..c40d995b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt @@ -177,7 +177,7 @@ class ResultFragmentTv : Fragment() { isVisible = true } - this.animate().alpha(if (turnVisible) 1.0f else 0.0f).apply { + this.animate().alpha(if (turnVisible) 0.97f else 0.0f).apply { duration = 200 interpolator = DecelerateInterpolator() setListener(object : Animator.AnimatorListener { @@ -294,9 +294,9 @@ class ResultFragmentTv : Fragment() { toggleEpisodes(true) binding?.apply { val views = listOf( + resultDubSelection, resultSeasonSelection, resultRangeSelection, - resultDubSelection, resultEpisodes, resultPlayTrailer, ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index b398b54e..6acf476a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -518,7 +518,8 @@ class ResultViewModel2 : ViewModel() { val episodeNumber = episodes[currentIndex].episode if (episodeNumber < currentMin) { currentMin = episodeNumber - } else if (episodeNumber > currentMax) { + } + if (episodeNumber > currentMax) { currentMax = episodeNumber } ++currentIndex diff --git a/app/src/main/res/drawable/episodes_shadow.xml b/app/src/main/res/drawable/episodes_shadow.xml index b4cdd382..a77cbf25 100644 --- a/app/src/main/res/drawable/episodes_shadow.xml +++ b/app/src/main/res/drawable/episodes_shadow.xml @@ -1,6 +1,8 @@ + android:centerColor="?attr/primaryBlackBackground" + android:centerX="0.2" + android:endColor="?attr/primaryBlackBackground" + android:startColor="@color/transparent" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_result_tv.xml b/app/src/main/res/layout/fragment_result_tv.xml index 4d236d78..a143fbda 100644 --- a/app/src/main/res/layout/fragment_result_tv.xml +++ b/app/src/main/res/layout/fragment_result_tv.xml @@ -535,129 +535,150 @@ https://developer.android.com/design/ui/tv/samples/jet-fit - - - - - - - - - - - - - + tools:visibility="visible"> - + - style="@style/Widget.AppCompat.ProgressBar" - android:layout_gravity="center" - android:layout_width="50dp" - android:layout_height="50dp" />--> + + - - - - + android:focusableInTouchMode="false" + android:importantForAccessibility="no" + android:src="@drawable/episodes_shadow" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="@+id/shadow_space_2" + app:layout_constraintTop_toTopOf="parent" /> + + + + + + + + + + + + + + + + + + - Date: Fri, 15 Sep 2023 16:41:13 +0200 Subject: [PATCH 24/37] Revert targetSdk --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7bcae0f4..66ba16c6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,7 +56,7 @@ android { defaultConfig { applicationId = "com.lagradost.cloudstream3" minSdk = 21 - targetSdk = 29 + targetSdk = 33 versionCode = 60 versionName = "4.1.9" From 65c927496d95f82e59cc268103f1aaa3524a3d8f Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Fri, 15 Sep 2023 17:51:15 +0200 Subject: [PATCH 25/37] Make account homepage persistent --- .../lagradost/cloudstream3/MainActivity.kt | 12 ++++++- .../cloudstream3/ui/home/HomeFragment.kt | 3 +- .../cloudstream3/ui/home/HomeViewModel.kt | 17 ++++++---- .../ui/settings/SettingsProviders.kt | 4 +-- .../ui/setup/SetupFragmentMedia.kt | 4 +-- .../cloudstream3/utils/DataStoreHelper.kt | 33 ++++++++++++++++--- 6 files changed, 55 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index c57b6c0f..7e29e727 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -128,6 +128,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.IOnBackPressed @@ -305,6 +306,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { // kinda shitty solution, but cant com main->home otherwise for popups val bookmarksUpdatedEvent = Event() + /** + * Used by data store helper to fully reload home when switching accounts + */ + val reloadHomeEvent = Event() /** @@ -1187,7 +1192,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } } else if (lastError == null) { ioSafe { - getKey(USER_SELECTED_HOMEPAGE_API)?.let { homeApi -> + DataStoreHelper.currentHomePage?.let { homeApi -> mainPluginsLoadedEvent.invoke(loadSinglePlugin(this@MainActivity, homeApi)) } ?: run { mainPluginsLoadedEvent.invoke(false) @@ -1548,6 +1553,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { migrateResumeWatching() } + getKey(USER_SELECTED_HOMEPAGE_API)?.let { homepage -> + DataStoreHelper.currentHomePage = homepage + removeKey(USER_SELECTED_HOMEPAGE_API) + } + try { if (getKey(HAS_DONE_SETUP_KEY, false) != true) { navController.navigate(R.id.navigation_setup_language) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index b84c619e..0797e9a0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -69,7 +69,6 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes -import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API import java.util.* @@ -669,7 +668,7 @@ class HomeFragment : Fragment() { } homeViewModel.reloadStored() - homeViewModel.loadAndCancel(getKey(USER_SELECTED_HOMEPAGE_API), false) + homeViewModel.loadAndCancel(DataStoreHelper.currentHomePage, false) //loadHomePage(false) // nice profile pic on homepage diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index b27223ec..13d34b59 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -49,7 +49,6 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API import com.lagradost.cloudstream3.utils.VideoDownloadHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -426,23 +425,29 @@ class HomeViewModel : ViewModel() { } private fun afterPluginsLoaded(forceReload: Boolean) { - loadAndCancel(getKey(USER_SELECTED_HOMEPAGE_API), forceReload) + loadAndCancel(DataStoreHelper.currentHomePage, forceReload) } private fun afterMainPluginsLoaded(unused: Boolean = false) { - loadAndCancel(getKey(USER_SELECTED_HOMEPAGE_API), false) + loadAndCancel(DataStoreHelper.currentHomePage, false) + } + + private fun reloadHome(unused: Boolean = false) { + loadAndCancel(DataStoreHelper.currentHomePage, true) } init { MainActivity.bookmarksUpdatedEvent += ::bookmarksUpdated MainActivity.afterPluginsLoadedEvent += ::afterPluginsLoaded MainActivity.mainPluginsLoadedEvent += ::afterMainPluginsLoaded + MainActivity.reloadHomeEvent += ::reloadHome } override fun onCleared() { MainActivity.bookmarksUpdatedEvent -= ::bookmarksUpdated MainActivity.afterPluginsLoadedEvent -= ::afterPluginsLoaded MainActivity.mainPluginsLoadedEvent -= ::afterMainPluginsLoaded + MainActivity.reloadHomeEvent -= ::reloadHome super.onCleared() } @@ -495,7 +500,7 @@ class HomeViewModel : ViewModel() { val api = getApiFromNameNull(preferredApiName) if (preferredApiName == noneApi.name) { // just set to random - if (fromUI) setKey(USER_SELECTED_HOMEPAGE_API, noneApi.name) + if (fromUI) DataStoreHelper.currentHomePage = noneApi.name loadAndCancel(noneApi) } else if (preferredApiName == randomApi.name) { // randomize the api, if none exist like if not loaded or not installed @@ -506,7 +511,7 @@ class HomeViewModel : ViewModel() { } else { val apiRandom = validAPIs.random() loadAndCancel(apiRandom) - if (fromUI) setKey(USER_SELECTED_HOMEPAGE_API, apiRandom.name) + if (fromUI) DataStoreHelper.currentHomePage = apiRandom.name } } else if (api == null) { // API is not found aka not loaded or removed, post the loading @@ -520,7 +525,7 @@ class HomeViewModel : ViewModel() { } } else { // if the api is found, then set it to it and save key - if (fromUI) setKey(USER_SELECTED_HOMEPAGE_API, api.name) + if (fromUI) DataStoreHelper.currentHomePage = api.name loadAndCancel(api) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt index 0bef5e9a..7e57fc5b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt @@ -14,7 +14,7 @@ import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard @@ -96,7 +96,7 @@ class SettingsProviders : PreferenceFragmentCompat() { this.getString(R.string.prefer_media_type_key), selectedList.map { it.toString() }.toMutableSet() ).apply() - removeKey(USER_SELECTED_HOMEPAGE_API) + DataStoreHelper.currentHomePage = null //(context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt index 6916cafe..f9197213 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt @@ -15,8 +15,8 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.FragmentSetupMediaBinding import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API class SetupFragmentMedia : Fragment() { @@ -77,7 +77,7 @@ class SetupFragmentMedia : Fragment() { .apply() // Regenerate set homepage - removeKey(USER_SELECTED_HOMEPAGE_API) + DataStoreHelper.currentHomePage = null } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 2eb2ab01..7bce1b6c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -77,10 +77,28 @@ object DataStoreHelper { var selectedKeyIndex by PreferenceDelegate("$TAG/account_key_index", 0) val currentAccount: String get() = selectedKeyIndex.toString() - private fun setAccount(account: Account) { + /** + * Get or set the current account homepage. + * Setting this does not automatically reload the homepage. + */ + var currentHomePage: String? + get() = getKey("$currentAccount/$USER_SELECTED_HOMEPAGE_API") + set(value) { + val key = "$currentAccount/$USER_SELECTED_HOMEPAGE_API" + if (value == null) { + removeKey(key) + } else { + setKey(key, value) + } + } + + private fun setAccount(account: Account, refreshHomePage: Boolean) { selectedKeyIndex = account.keyIndex showToast(account.name) MainActivity.bookmarksUpdatedEvent(true) + if (refreshHomePage) { + MainActivity.reloadHomeEvent(true) + } } private fun editAccount(context: Context, account: Account, isNewAccount: Boolean) { @@ -112,7 +130,7 @@ object DataStoreHelper { accounts = currentAccounts.toTypedArray() // update UI - setAccount(getDefaultAccount(context)) + setAccount(getDefaultAccount(context), true) MainActivity.bookmarksUpdatedEvent(true) dialog?.dismissSafe() } @@ -161,8 +179,13 @@ object DataStoreHelper { currentAccounts.add(currentEditAccount) } + // Save the current homepage for new accounts + val currentHomePage = DataStoreHelper.currentHomePage + // set the new default account as well as add the key for the new account - setAccount(currentEditAccount) + setAccount(currentEditAccount, false) + DataStoreHelper.currentHomePage = currentHomePage + accounts = currentAccounts.toTypedArray() dialog.dismissSafe() @@ -204,7 +227,7 @@ object DataStoreHelper { ) binding.profilesRecyclerview.adapter = WhoIsWatchingAdapter( selectCallBack = { account -> - setAccount(account) + setAccount(account, true) builder.dismissSafe() }, addAccountCallback = { @@ -353,7 +376,7 @@ object DataStoreHelper { removeKeys(folder2) } - fun deleteBookmarkedData(id : Int?) { + fun deleteBookmarkedData(id: Int?) { if (id == null) return removeKey("$currentAccount/$RESULT_WATCH_STATE", id.toString()) removeKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) From 2c3bdafd4530530bd0b5130122358288836c4451 Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Fri, 22 Sep 2023 14:33:04 +0200 Subject: [PATCH 26/37] fix conflicts --- .../cloudstream3/ui/home/HomeFragment.kt | 15 +- .../ui/library/LibraryFragment.kt | 7 +- .../ui/settings/SettingsAccount.kt | 131 +----------------- .../helpers/settings/account/DialogBuilder.kt | 11 +- .../account/InAppAuthDialogBuilder.kt | 63 +++++---- .../account/InAppOAuth2DialogBuilder.kt | 61 ++++---- .../ui/setup/SetupFragmentLanguage.kt | 2 +- .../ui/setup/SetupFragmentLayout.kt | 2 +- .../ui/setup/SetupFragmentMedia.kt | 5 +- .../ui/setup/SetupFragmentProviderLanguage.kt | 2 +- 10 files changed, 85 insertions(+), 214 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index 49ad98f6..3e6ba6ab 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -7,7 +7,6 @@ import android.content.DialogInterface import android.content.Intent import android.content.res.Configuration import android.net.Uri -import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -23,9 +22,7 @@ import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.button.MaterialButton import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipGroup import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia @@ -47,15 +44,12 @@ import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi import com.lagradost.cloudstream3.ui.WatchType -import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.search.* import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.addProgramsToContinueWatching import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable -import com.lagradost.cloudstream3.utils.AppUtils.loadResult import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppUtils.ownHide import com.lagradost.cloudstream3.utils.AppUtils.ownShow @@ -64,13 +58,12 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentHomePage import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes - import java.util.* @@ -523,7 +516,7 @@ class HomeFragment : Fragment() { } private fun reloadStored(unused: Unit = Unit) { - homeViewModel.loadResumeWatching() + homeViewModel.reloadStored() val list = EnumSet.noneOf(WatchType::class.java) getKey(HOME_BOOKMARK_VALUE_LIST)?.map { WatchType.fromInternalId(it) }?.let { list.addAll(it) @@ -540,7 +533,7 @@ class HomeFragment : Fragment() { } private fun loadHomePage(forceReload: Boolean) { - val apiName = context?.getKey(USER_SELECTED_HOMEPAGE_API) + val apiName = currentHomePage if (homeViewModel.apiName.value != apiName || apiName == null || forceReload) { //println("Caught home: " + homeViewModel.apiName.value + " at " + apiName) @@ -552,7 +545,7 @@ class HomeFragment : Fragment() { if (callback.action == SEARCH_ACTION_FOCUSED) { //focusCallback(callback.card) } else { - handleSearchClickCallback(activity, callback) + handleSearchClickCallback(callback) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt index 9affef8c..ab2af7ce 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt @@ -15,7 +15,6 @@ import android.view.animation.AlphaAnimation import androidx.annotation.StringRes import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import com.google.android.material.tabs.TabLayoutMediator import com.lagradost.cloudstream3.APIHolder @@ -93,12 +92,8 @@ class LibraryFragment : Fragment() { override fun onDestroyView() { binding = null - super.onDestroyView() - } - - override fun onDestroyView() { - super.onDestroyView() MainActivity.afterBackupRestoreEvent -= ::onNewSyncData + super.onDestroyView() } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 49dadce4..f3325227 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -12,9 +12,8 @@ import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AccountManagmentBinding import com.lagradost.cloudstream3.databinding.AccountSwitchBinding -import com.lagradost.cloudstream3.databinding.AddAccountInputBinding import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.* +import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.googleDriveApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi @@ -22,6 +21,7 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSub import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI +import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API import com.lagradost.cloudstream3.syncproviders.OAuth2API import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings @@ -108,129 +108,12 @@ class SettingsAccount : PreferenceFragmentCompat() { } @UiThread - fun addAccount(activity: FragmentActivity?, api: AccountManager) { + fun addAccount(activity: FragmentActivity, api: AccountManager) { try { when (api) { - is OAuth2API -> { - api.authenticate(activity) - } - - is InAppAuthAPI -> { - if (activity == null) return - val binding: AddAccountInputBinding = - AddAccountInputBinding.inflate(activity.layoutInflater, null, false) - val builder = - AlertDialog.Builder(activity, R.style.AlertDialogCustom) - .setView(binding.root) - val dialog = builder.show() - - val visibilityMap = listOf( - binding.loginEmailInput to api.requiresEmail, - binding.loginPasswordInput to api.requiresPassword, - binding.loginServerInput to api.requiresServer, - binding.loginUsernameInput to api.requiresUsername - ) - - if (isTvSettings()) { - visibilityMap.forEach { (input, isVisible) -> - input.isVisible = isVisible - - // Band-aid for weird FireTV behavior causing crashes because keyboard covers the screen - input.setOnEditorActionListener { textView, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_NEXT) { - val view = textView.focusSearch(FOCUS_DOWN) - return@setOnEditorActionListener view?.requestFocus( - FOCUS_DOWN - ) == true - } - return@setOnEditorActionListener true - } - } - } else { - visibilityMap.forEach { (input, isVisible) -> - input.isVisible = isVisible - } - } - - binding.loginEmailInput.isVisible = api.requiresEmail - binding.loginPasswordInput.isVisible = api.requiresPassword - binding.loginServerInput.isVisible = api.requiresServer - binding.loginUsernameInput.isVisible = api.requiresUsername - binding.createAccount.isGone = api.createAccountUrl.isNullOrBlank() - binding.createAccount.setOnClickListener { - openBrowser( - api.createAccountUrl ?: return@setOnClickListener, - activity - ) - dialog.dismissSafe() - } - - val displayedItems = listOf( - binding.loginUsernameInput, - binding.loginEmailInput, - binding.loginServerInput, - binding.loginPasswordInput - ).filter { it.isVisible } - - displayedItems.foldRight(displayedItems.firstOrNull()) { item, previous -> - item.id.let { previous?.nextFocusDownId = it } - previous?.id?.let { item.nextFocusUpId = it } - item - } - - displayedItems.firstOrNull()?.let { - binding.createAccount.nextFocusDownId = it.id - it.nextFocusUpId = binding.createAccount.id - } - binding.applyBtt.id.let { - displayedItems.lastOrNull()?.nextFocusDownId = it - } - - binding.text1.text = api.name - - if (api.storesPasswordInPlainText) { - api.getLatestLoginData()?.let { data -> - binding.loginEmailInput.setText(data.email ?: "") - binding.loginServerInput.setText(data.server ?: "") - binding.loginUsernameInput.setText(data.username ?: "") - binding.loginPasswordInput.setText(data.password ?: "") - } - } - - binding.applyBtt.setOnClickListener { - val loginData = InAppAuthAPI.LoginData( - username = if (api.requiresUsername) binding.loginUsernameInput.text?.toString() else null, - password = if (api.requiresPassword) binding.loginPasswordInput.text?.toString() else null, - email = if (api.requiresEmail) binding.loginEmailInput.text?.toString() else null, - server = if (api.requiresServer) binding.loginServerInput.text?.toString() else null, - ) - ioSafe { - val isSuccessful = try { - api.login(loginData) - } catch (e: Exception) { - logError(e) - false - } - activity.runOnUiThread { - try { - showToast( - activity.getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail) - .format( - api.name - ) - ) - } catch (e: Exception) { - logError(e) // format might fail - } - } - } - dialog.dismissSafe(activity) - } - binding.cancelBtt.setOnClickListener { - dialog.dismissSafe(activity) - } - } - + is InAppOAuth2API -> InAppOAuth2DialogBuilder(api, activity).open() + is OAuth2API -> api.authenticate(activity) + is InAppAuthAPI -> InAppAuthDialogBuilder(api, activity).open() else -> { throw NotImplementedError("You are trying to add an account that has an unknown login method") } @@ -269,7 +152,7 @@ class SettingsAccount : PreferenceFragmentCompat() { if (info != null) { showLoginInfo(activity, api, info) } else { - addAccount(activity, api) + activity?.let { activity -> addAccount(activity, api) } } return@setOnPreferenceClickListener true } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt index c493a104..0686cdc3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt @@ -6,16 +6,17 @@ import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity +import androidx.viewbinding.ViewBinding import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.ui.settings.SettingsFragment import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -abstract class DialogBuilder( +abstract class DialogBuilder( private val api: AuthAPI, private val activity: FragmentActivity?, private val themeResId: Int, - private val layoutResId: Int, + val binding: T, ) { class CommonDialogItems( private val dialog: AlertDialog, @@ -57,7 +58,7 @@ abstract class DialogBuilder( return null } - val dialogBuilder = AlertDialog.Builder(activity, themeResId).setView(layoutResId) + val dialogBuilder = AlertDialog.Builder(activity, themeResId).setView(binding.root) val dialog = dialogBuilder.show() setup(dialog) @@ -111,8 +112,8 @@ abstract class DialogBuilder( val displayedItems = getVisibilityMap(dialog).keys.filter { it.isVisible } displayedItems.foldRight(displayedItems.firstOrNull()) { item, previous -> - item?.id?.let { previous?.nextFocusDownId = it } - previous?.id?.let { item?.nextFocusUpId = it } + item.id.let { previous?.nextFocusDownId = it } + previous?.id?.let { item.nextFocusUpId = it } item } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt index 47ec545e..52a61bf8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt @@ -8,30 +8,30 @@ import androidx.fragment.app.FragmentActivity import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.AddAccountInputBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import kotlinx.android.synthetic.main.add_account_input.* class InAppAuthDialogBuilder( private val api: InAppAuthAPI, - private val activity: FragmentActivity?, -) : DialogBuilder( + private val activity: FragmentActivity, +) : DialogBuilder( api, activity, R.style.AlertDialogCustom, - R.layout.add_account_input, + AddAccountInputBinding.inflate(activity.layoutInflater), ) { - override fun onLogin(dialog: AlertDialog): Unit = with(dialog) { - if (activity == null) throw IllegalStateException("Login should be called after validation") + override fun onLogin(dialog: AlertDialog): Unit = with(binding) { +// if (activity == null) throw IllegalStateException("Login should be called after validation") val loginData = InAppAuthAPI.LoginData( - username = if (api.requiresUsername) login_username_input?.text?.toString() else null, - password = if (api.requiresPassword) login_password_input?.text?.toString() else null, - email = if (api.requiresEmail) login_email_input?.text?.toString() else null, - server = if (api.requiresServer) login_server_input?.text?.toString() else null, + username = if (api.requiresUsername) loginUsernameInput.text?.toString() else null, + password = if (api.requiresPassword) loginPasswordInput.text?.toString() else null, + email = if (api.requiresEmail) loginEmailInput.text?.toString() else null, + server = if (api.requiresServer) loginServerInput.text?.toString() else null, ) ioSafe { @@ -41,6 +41,9 @@ class InAppAuthDialogBuilder( logError(e) false } + if (isSuccessful) { + dialog.dismissSafe() + } activity.runOnUiThread { try { CommonActivity.showToast( @@ -58,43 +61,43 @@ class InAppAuthDialogBuilder( } - override fun getCommonItems(dialog: AlertDialog) = with(dialog) { - CommonDialogItems(dialog, text1, apply_btt, cancel_btt, create_account,null) + override fun getCommonItems(dialog: AlertDialog) = with(binding) { + CommonDialogItems(dialog, text1, applyBtt, cancelBtt, createAccount,null) } - override fun getVisibilityMap(dialog: AlertDialog): Map = with(dialog) { + override fun getVisibilityMap(dialog: AlertDialog): Map = with(binding) { mapOf( - login_email_input to api.requiresEmail, - login_password_input to api.requiresPassword, - login_server_input to api.requiresServer, - login_username_input to api.requiresUsername + loginEmailInput to api.requiresEmail, + loginPasswordInput to api.requiresPassword, + loginServerInput to api.requiresServer, + loginUsernameInput to api.requiresUsername ) } - override fun setupItems(dialog: AlertDialog): Unit = with(dialog) { - login_email_input?.isVisible = api.requiresEmail - login_password_input?.isVisible = api.requiresPassword - login_server_input?.isVisible = api.requiresServer - login_username_input?.isVisible = api.requiresUsername + override fun setupItems(dialog: AlertDialog): Unit = with(binding) { + loginEmailInput.isVisible = api.requiresEmail + loginPasswordInput.isVisible = api.requiresPassword + loginServerInput.isVisible = api.requiresServer + loginUsernameInput.isVisible = api.requiresUsername - create_account?.isGone = api.createAccountUrl.isNullOrBlank() - create_account?.setOnClickListener { + createAccount.isGone = api.createAccountUrl.isNullOrBlank() + createAccount.setOnClickListener { AcraApplication.openBrowser( api.createAccountUrl ?: return@setOnClickListener, activity ) - dismissSafe() + dialog.dismissSafe() } } - override fun handleStoresPasswordInPlainText(dialog: AlertDialog): Unit = with(dialog) { + override fun handleStoresPasswordInPlainText(dialog: AlertDialog): Unit = with(binding) { if (!api.storesPasswordInPlainText) return api.getLatestLoginData()?.let { data -> - login_email_input?.setText(data.email ?: "") - login_server_input?.setText(data.server ?: "") - login_username_input?.setText(data.username ?: "") - login_password_input?.setText(data.password ?: "") + loginEmailInput.setText(data.email ?: "") + loginServerInput.setText(data.server ?: "") + loginUsernameInput.setText(data.username ?: "") + loginPasswordInput.setText(data.password ?: "") } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt index 1c15e7ea..9abe0eb9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt @@ -8,66 +8,65 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.AddAccountInputOauthBinding import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import kotlinx.android.synthetic.main.add_account_input_oauth.apply_btt -import kotlinx.android.synthetic.main.add_account_input_oauth.cancel_btt -import kotlinx.android.synthetic.main.add_account_input_oauth.info_button -import kotlinx.android.synthetic.main.add_account_input_oauth.login_client_id -import kotlinx.android.synthetic.main.add_account_input_oauth.login_client_secret -import kotlinx.android.synthetic.main.add_account_input_oauth.login_file_name -import kotlinx.android.synthetic.main.add_account_input_oauth.text1 class InAppOAuth2DialogBuilder( private val api: InAppOAuth2API, - private val activity: FragmentActivity?, -) : DialogBuilder(api, activity, R.style.AlertDialogCustom, R.layout.add_account_input_oauth) { - override fun getCommonItems(dialog: AlertDialog) = with(dialog) { - CommonDialogItems(dialog, text1, apply_btt, cancel_btt, null, info_button) + private val activity: FragmentActivity, +) : DialogBuilder( + api, activity, R.style.AlertDialogCustom, + AddAccountInputOauthBinding.inflate(activity.layoutInflater) +) { + override fun getCommonItems(dialog: AlertDialog) = with(binding) { + CommonDialogItems(dialog, text1, applyBtt, cancelBtt, null, infoButton) } - override fun getVisibilityMap(dialog: AlertDialog): Map = with(dialog) { + override fun getVisibilityMap(dialog: AlertDialog): Map = with(binding) { mapOf( - login_file_name to api.requiresFilename, - login_client_id to api.requiresClientId, - login_client_secret to api.requiresSecret, + loginFileName to api.requiresFilename, + loginClientId to api.requiresClientId, + loginClientSecret to api.requiresSecret, ) } - override fun setupItems(dialog: AlertDialog): Unit = with(dialog) { - login_file_name?.isVisible = api.requiresFilename - login_client_id?.isVisible = api.requiresClientId - login_client_secret?.isVisible = api.requiresSecret + override fun setupItems(dialog: AlertDialog): Unit = with(binding) { + loginFileName.isVisible = api.requiresFilename + loginClientId.isVisible = api.requiresClientId + loginClientSecret.isVisible = api.requiresSecret - info_button?.isGone = api.infoUrl.isNullOrBlank() - info_button?.setOnClickListener { + infoButton.isGone = api.infoUrl.isNullOrBlank() + infoButton.setOnClickListener { val customTabIntent = CustomTabsIntent.Builder().setShowTitle(true).build() - customTabIntent.launchUrl(context, Uri.parse(api.infoUrl)) + customTabIntent.launchUrl(binding.root.context, Uri.parse(api.infoUrl)) } } - override fun onLogin(dialog: AlertDialog): Unit = with(activity) { - if (this == null) throw IllegalStateException("Login should be called after validation") + override fun onLogin(dialog: AlertDialog): Unit = with(binding) { +// if (this == null) throw IllegalStateException("Login should be called after validation") - val clientId = dialog.login_client_id.text.toString().ifBlank { - getString(R.string.debug_gdrive_clientId) + val ctx = this.root.context + + val clientId = loginClientId.text.toString().ifBlank { + ctx.getString(R.string.debug_gdrive_clientId) } - val clientSecret = dialog.login_client_secret.text.toString().ifBlank { - getString(R.string.debug_gdrive_secret) + val clientSecret = loginClientSecret.text.toString().ifBlank { + ctx.getString(R.string.debug_gdrive_secret) } - val syncFileName = dialog.login_file_name.text.toString().trim().ifBlank { + val syncFileName = loginFileName.text.toString().trim().ifBlank { api.defaultFilenameValue } - val redirectUrl = dialog.login_file_name.text.toString().trim().ifBlank { + val redirectUrl = loginFileName.text.toString().trim().ifBlank { api.defaultRedirectUrl } ioSafe { api.getAuthorizationToken( - this@with, + this@InAppOAuth2DialogBuilder.activity, InAppOAuth2API.LoginData( clientId = clientId, secret = clientSecret, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt index 35bf7ba9..7fdff812 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt @@ -53,7 +53,7 @@ class SetupFragmentLanguage : Fragment() { val ctx = context ?: return@normalSafeApiCall val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - .attachBackupListener(getSyncPrefs()).self + .attachBackupListener(ctx.getSyncPrefs()).self val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt index 90cce5f1..e6039105 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt @@ -45,7 +45,7 @@ class SetupFragmentLayout : Fragment() { val ctx = context ?: return@normalSafeApiCall val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - .attachBackupListener(getSyncPrefs()).self + .attachBackupListener(ctx.getSyncPrefs()).self val prefNames = resources.getStringArray(R.array.app_layout) val prefValues = resources.getIntArray(R.array.app_layout_values) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt index df750d22..3464f979 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt @@ -10,7 +10,6 @@ import androidx.core.util.forEach import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.FragmentSetupMediaBinding @@ -19,8 +18,6 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs -import com.lagradost.cloudstream3.utils.DataStore.removeKey -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar class SetupFragmentMedia : Fragment() { @@ -49,7 +46,7 @@ class SetupFragmentMedia : Fragment() { val ctx = context ?: return@normalSafeApiCall val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - .attachBackupListener(getSyncPrefs()).self + .attachBackupListener(ctx.getSyncPrefs()).self val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt index d41f3eb6..3ad26136 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt @@ -48,7 +48,7 @@ class SetupFragmentProviderLanguage : Fragment() { val ctx = context ?: return@normalSafeApiCall val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - .attachBackupListener(getSyncPrefs()).self + .attachBackupListener(ctx.getSyncPrefs()).self val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) From 923e93a69212523bb92586c9a94ffed9061390ce Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Fri, 27 Oct 2023 13:23:12 +0200 Subject: [PATCH 27/37] Google profile picture --- .../lagradost/cloudstream3/MainActivity.kt | 4 ++ .../syncproviders/providers/GoogleDriveApi.kt | 51 +++++++++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index ca5129aa..158fabb7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -90,6 +90,7 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStri import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringSearch import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths +import com.lagradost.cloudstream3.syncproviders.providers.localnetwork.LocalNetworkApi import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO @@ -1595,6 +1596,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { // } // } + val local = LocalNetworkApi(this) + local.registerService() + local.discover() } suspend fun checkGithubConnectivity(): Boolean { diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index 513c79d4..573ed6f0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -18,6 +18,8 @@ import com.google.api.services.drive.Drive import com.google.api.services.drive.DriveScopes import com.google.api.services.drive.model.File import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.syncproviders.AuthAPI @@ -28,8 +30,10 @@ import com.lagradost.cloudstream3.syncproviders.InAppOAuth2APIManager import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.BackupUtils.getBackup import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe import com.lagradost.cloudstream3.utils.Scheduler import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking import java.io.InputStream import java.util.Date @@ -71,7 +75,8 @@ class GoogleDriveApi(index: Int) : override val requiresSecret = true override val requiresClientId = true override val defaultFilenameValue = "cloudstreamapp-sync-file" - override val defaultRedirectUrl = "https://recloudstream.github.io/cloudstream-sync/google-drive" + override val defaultRedirectUrl = + "https://recloudstream.github.io/cloudstream-sync/google-drive" override val infoUrl = "https://recloudstream.github.io/cloudstream-sync/google-drive/help.html" override var isActive: Boolean? = false @@ -81,6 +86,10 @@ class GoogleDriveApi(index: Int) : private var tempAuthFlow: AuthorizationCodeFlow? = null private var lastBackupJson: String? = null + companion object { + const val GOOGLE_ACCOUNT_INFO_KEY = "google_account_info_key" + } + ///////////////////////////////////////// ///////////////////////////////////////// // OAuth2API implementation @@ -137,11 +146,47 @@ class GoogleDriveApi(index: Int) : } } + private suspend fun fetchUserInfo(driveService: Drive): GoogleUser? { + return ioWorkSafe { + val user = driveService.about() + .get() + .apply { + this.fields = "user" + } + .execute() + .user + GoogleUser(user.displayName, user.photoLink) + } + } + + private suspend fun getUserInfo(driveService: Drive): GoogleUser? { + return getKey(accountId, GOOGLE_ACCOUNT_INFO_KEY) + ?: fetchUserInfo(driveService).also { user -> + setKey(accountId, GOOGLE_ACCOUNT_INFO_KEY, user) + } + } + + data class GoogleUser( + val displayName: String, + val photoLink: String?, + ) + + private fun getBlankUser(): GoogleUser { + return GoogleUser( + "google-account-$accountIndex", + null, + ) + } + override fun loginInfo(): AuthAPI.LoginInfo? { - getCredentialsFromStore() ?: return null + val driveService = getDriveService() ?: return null + val userInfo = runBlocking { + getUserInfo(driveService) + } ?: getBlankUser() return AuthAPI.LoginInfo( - name = "google-account-$accountIndex", + name = userInfo.displayName, + profilePicture = userInfo.photoLink, accountIndex = accountIndex ) } From 4b7fc622370dad989a814dad859369c63cafb779 Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Sun, 29 Oct 2023 17:38:01 +0100 Subject: [PATCH 28/37] Add Pcloud and refactor --- .../lagradost/cloudstream3/MainActivity.kt | 7 +- .../syncproviders/AccountManager.kt | 17 +- .../cloudstream3/syncproviders/BackupAPI.kt | 440 ++++++++++++------ .../syncproviders/InAppAuthAPI.kt | 12 +- .../syncproviders/InAppOAuth2API.kt | 33 -- .../syncproviders/providers/GoogleDriveApi.kt | 231 ++++----- .../providers/OpenSubtitlesApi.kt | 6 +- .../syncproviders/providers/PcloudApi.kt | 198 ++++++++ .../ui/settings/SettingsAccount.kt | 4 +- .../ui/settings/SettingsFragment.kt | 20 + .../account/InAppAuthDialogBuilder.kt | 6 +- .../cloudstream3/utils/BackupUtils.kt | 15 +- .../lagradost/cloudstream3/utils/DataStore.kt | 44 +- .../lagradost/cloudstream3/utils/Scheduler.kt | 107 +++-- .../main/res/drawable/baseline_cloud_24.xml | 5 + app/src/main/res/layout/main_settings.xml | 13 +- app/src/main/res/values/strings.xml | 3 + app/src/main/res/xml/settings_account.xml | 4 + 18 files changed, 754 insertions(+), 411 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/PcloudApi.kt create mode 100644 app/src/main/res/drawable/baseline_cloud_24.xml diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 158fabb7..4a9fbd62 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -90,7 +90,6 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStri import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringSearch import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths -import com.lagradost.cloudstream3.syncproviders.providers.localnetwork.LocalNetworkApi import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO @@ -685,7 +684,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { this.sendBroadcast(broadcastIntent) afterPluginsLoadedEvent -= ::onAllPluginsLoaded // run sync before app quits - BackupApis.forEach { it.addToQueueNow() } + BackupApis.forEach { it.scheduleUpload() } super.onDestroy() } @@ -1595,10 +1594,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { // showToast(this, currentFocus.toString(), Toast.LENGTH_LONG) // } // } - - val local = LocalNetworkApi(this) - local.registerService() - local.discover() } suspend fun checkGithubConnectivity(): Boolean { diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt index 54128112..b73105af 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -14,6 +14,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { val openSubtitlesApi = OpenSubtitlesApi(0) val simklApi = SimklApi(0) val googleDriveApi = GoogleDriveApi(0) + val pcloudApi = PcloudApi(0) val indexSubtitlesApi = IndexSubtitleApi() val addic7ed = Addic7ed() val localListApi = LocalList() @@ -21,13 +22,18 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { // used to login via app intent val OAuth2Apis get() = listOf( - malApi, aniListApi, simklApi, googleDriveApi + malApi, aniListApi, simklApi, googleDriveApi, pcloudApi ) // this needs init with context and can be accessed in settings val accountManagers get() = listOf( - malApi, aniListApi, openSubtitlesApi, simklApi, googleDriveApi //, nginxApi + malApi, + aniListApi, + openSubtitlesApi, + simklApi, + googleDriveApi, + pcloudApi //, nginxApi ) // used for active syncing @@ -39,12 +45,12 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { // used for active backup val BackupApis get() = listOf>( - googleDriveApi + googleDriveApi, pcloudApi ) val inAppAuths get() = listOf( - openSubtitlesApi, googleDriveApi//, nginxApi + openSubtitlesApi, googleDriveApi, pcloudApi//, nginxApi ) val subtitleProviders @@ -94,7 +100,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { var accountIndex = defIndex private var lastAccountIndex = defIndex - protected val accountId get() = "${idPrefix}_account_$accountIndex" + val accountId get() = "${idPrefix}_account_$accountIndex" private val accountActiveKey get() = "${idPrefix}_active" // int array of all accounts indexes @@ -132,6 +138,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { lastAccountIndex = accountIndex accountIndex = (accounts?.maxOrNull() ?: 0) + 1 } + protected fun switchToOldAccount() { accountIndex = lastAccountIndex } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt index 66acb5f4..1a292195 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -4,14 +4,19 @@ import android.content.Context import android.content.SharedPreferences import android.util.Log import com.fasterxml.jackson.module.kotlin.readValue -import com.lagradost.cloudstream3.mvvm.launchSafe +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.mvvm.debugException +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.LOG_KEY +import com.lagradost.cloudstream3.syncproviders.IBackupAPI.Companion.compareJson +import com.lagradost.cloudstream3.syncproviders.IBackupAPI.Companion.mergeBackup +import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.getBackup import com.lagradost.cloudstream3.utils.BackupUtils.restore +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStore import com.lagradost.cloudstream3.utils.Scheduler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import org.skyscreamer.jsonassert.JSONCompare import org.skyscreamer.jsonassert.JSONCompareMode @@ -19,7 +24,180 @@ import org.skyscreamer.jsonassert.JSONCompareResult import kotlin.system.measureTimeMillis import kotlin.time.Duration.Companion.seconds -interface BackupAPI { +interface RemoteFile { + class Error(val message: String? = null, val throwable: Throwable? = null) : RemoteFile + class NotFound : RemoteFile + class Success(val remoteData: String) : RemoteFile +} + +abstract class BackupAPI(defIndex: Int) : IBackupAPI, + AccountManager(defIndex) { + companion object { + const val LOG_KEY = "BACKUP" + + // Can be called in high frequency (for now) because current implementation uses google + // cloud project per user so there is no way to hit quota. Later we should implement + // some kind of adaptive throttling which will increase decrease throttle time based + // on factors like: live devices, quota limits, etc + val UPLOAD_THROTTLE = 30.seconds + val DOWNLOAD_THROTTLE = 120.seconds + } + + /** + * Cached last uploaded json file, to prevent unnecessary uploads. + */ + private var lastBackupJson: String? = null + + /** + * Continually tries to download from the service. + */ + private val continuousDownloader = Scheduler( + DOWNLOAD_THROTTLE.inWholeMilliseconds, + onWork = { overwrite -> + if (uploadJob?.isActive == true || willUploadSoon == true) { + uploadJob?.invokeOnCompletion { + Log.d(LOG_KEY, "${this.name}: upload is running, reschedule download") + ioSafe { + scheduleDownload(false, overwrite) + } + } + } else { + Log.d(LOG_KEY, "${this.name}: downloadSyncData will run") + val context = AcraApplication.context ?: return@Scheduler + mergeRemoteBackup(context, overwrite) + } + } + ) + + suspend fun scheduleDownload(runNow: Boolean = false, overwrite: Boolean = false) { + if (runNow) { + continuousDownloader.workNow(overwrite) + } else { + continuousDownloader.work(overwrite) + } + } + + var willUploadSoon: Boolean? = null + private var uploadJob: Job? = null + + private fun shouldUploadBackup(): Boolean { + val ctx = AcraApplication.context ?: return false + + val newBackup = ctx.getBackup().toJson() + return compareJson(lastBackupJson ?: "", newBackup).failed + } + + fun scheduleUpload() { + if (!shouldUploadBackup()) { + willUploadSoon = false + Log.d(LOG_KEY, "${this.name}: upload not required, data is same") + return + } + + upload() + } + + // changedKey and isSettings is currently unused, might be useful for more efficient update checker. + fun scheduleUpload(changedKey: String, isSettings: Boolean) { + scheduleUpload() + } + + private fun upload() { + if (uploadJob != null && uploadJob!!.isActive) { + Log.d(LOG_KEY, "${this.name}: upload is canceled, scheduling new") + uploadJob?.cancel() + } + + val context = AcraApplication.context ?: return + uploadJob = ioSafe { + willUploadSoon = false + Log.d(LOG_KEY, "$name: uploadBackup is launched") + uploadBackup(context) + } + } + + /** + * Uploads the app data to the service if the api is ready and has login data. + * @see isReady + * @see getLoginData + */ + private suspend fun uploadBackup(context: Context) { + val isReady = isReady() + if (!isReady) { + Log.d(LOG_KEY, "${this.name}: uploadBackup is not ready yet") + return + } + + val loginData = getLoginData() + if (loginData == null) { + Log.d(LOG_KEY, "${this.name}: uploadBackup did not get loginData") + return + } + + val backupFile = context.getBackup().toJson() + lastBackupJson = backupFile + Log.d(LOG_KEY, "${this.name}: uploadFile is now running") + uploadFile(context, backupFile, loginData) + Log.d(LOG_KEY, "${this.name}: uploadFile finished") + } + + /** + * Gets the remote backup and properly handle any errors, including uploading the backup + * if no remote file was found. + */ + private suspend fun getRemoteBackup(context: Context): String? { + if (!isReady()) { + Log.d(LOG_KEY, "${this.name}: getRemoteBackup is not ready yet") + return null + } + + val loginData = getLoginData() + if (loginData == null) { + Log.d(LOG_KEY, "${this.name}: getRemoteBackup did not get loginData") + return null + } + + return when (val remoteFile = getRemoteFile(context, loginData)) { + is RemoteFile.NotFound -> { + Log.d(LOG_KEY, "${this.name}: Remote file not found. Uploading file.") + uploadBackup(context) + null + } + + is RemoteFile.Success -> { + Log.d(LOG_KEY, "${this.name}: Remote file found.") + remoteFile.remoteData + } + + is RemoteFile.Error -> { + Log.d(LOG_KEY, "${this.name}: getRemoteFile failed with message: ${remoteFile.message}.") + remoteFile.throwable?.let { error -> logError(error) } + null + } + + else -> { + val message = "${this.name}: Unexpected remote file!" + debugException { message } + Log.d(LOG_KEY, message) + null + } + } + } + + /** + * Gets the remote backup and merges it with the local data. + * Also saves a cached to prevent unnecessary uploading. + * @see getRemoteBackup + * @see mergeBackup + */ + private suspend fun mergeRemoteBackup(context: Context, overwrite: Boolean) { + val remoteData = getRemoteBackup(context) ?: return + lastBackupJson = remoteData + mergeBackup(context, remoteData, overwrite) + } +} + +interface IBackupAPI { data class JSONComparison( val failed: Boolean, val result: JSONCompareResult? @@ -38,173 +216,141 @@ interface BackupAPI { val scheduler: Scheduler> ) - companion object { - const val LOG_KEY = "BACKUP" - const val SYNC_HISTORY_PREFIX = "_hs/" + /** + * Gets the user login info for uploading and downloading the backup. + * If null no backup or download will be run. + */ + suspend fun getLoginData(): LOGIN_DATA? - // Can be called in high frequency (for now) because current implementation uses google - // cloud project per user so there is no way to hit quota. Later we should implement - // some kind of adaptive throttling which will increase decrease throttle time based - // on factors like: live devices, quota limits, etc - val UPLOAD_THROTTLE = 10.seconds - val DOWNLOAD_THROTTLE = 60.seconds - // add to queue may be called frequently - private val ioScope = CoroutineScope(Dispatchers.IO) + /** + * Additional check if the backup operation should be run. + * Return false here to deny any backup work. + */ + suspend fun isReady(): Boolean = true + + /** + * Get the backup file as a string from the remote storage. + * @see RemoteFile.Success + * @see RemoteFile.Error + * @see RemoteFile.NotFound + */ + suspend fun getRemoteFile(context: Context, loginData: LOGIN_DATA): RemoteFile + + suspend fun uploadFile( + context: Context, + backupJson: String, + loginData: LOGIN_DATA + ) + + companion object { + const val SYNC_HISTORY_PREFIX = "_hs/" fun SharedPreferences.logHistoryChanged(path: String, source: BackupUtils.RestoreSource) { edit().putLong("${source.syncPrefix}$path", System.currentTimeMillis()) .apply() } - } - /** - * isActive is recommended to be overridden to verifiy if BackupApi is being used. if manager - * is not set up it won't write sync data. - * @see Scheduler.Companion.createBackupScheduler - * @see SharedPreferences.logHistoryChanged - */ - var isActive: Boolean? - fun updateApiActiveState() { - this.isActive = this.isActive() - } - fun isActive(): Boolean - /** - * Should download data from API and call Context.mergeBackup(incomingData: String). If data - * does not exist on the api uploadSyncData() is recommended to call. Should be called with - * overwrite=true when user ads new account so it would accept changes from API - * @see Context.mergeBackup - * @see uploadSyncData - */ - fun downloadSyncData(overwrite: Boolean) + fun compareJson(old: String, new: String): JSONComparison { + var result: JSONCompareResult? - /** - * Should upload data to API and call Context.createBackup(loginData: LOGIN_DATA) - * @see Context.createBackup(loginData: LOGIN_DATA) - */ - fun uploadSyncData() + val executionTime = measureTimeMillis { + result = try { + JSONCompare.compareJSON(old, new, JSONCompareMode.NON_EXTENSIBLE) + } catch (e: Exception) { + null + } + } + val failed = result?.failed() ?: true + Log.d( + LOG_KEY, + "JSON comparison took $executionTime ms, compareFailed=$failed, result=$result" + ) - fun Context.createBackup(loginData: LOGIN_DATA) - fun Context.mergeBackup(incomingData: String, overwrite: Boolean) { - val newData = DataStore.mapper.readValue(incomingData) - if (overwrite) { - Log.d(LOG_KEY, "overwriting data") - restore(newData) - - return + return JSONComparison(failed, result) } - val keysToUpdate = getKeysToUpdate(getBackup(), newData) - if (keysToUpdate.isEmpty()) { - Log.d(LOG_KEY, "remote data is up to date, sync not needed") - return + private fun getSyncKeys(data: BackupUtils.BackupFile) = + data.syncMeta._Long.orEmpty().filter { it.key.startsWith(SYNC_HISTORY_PREFIX) } + + /** + * Merges the backup data with the app data. + * @param overwrite if true it overwrites all data same as restoring from a backup. + * if false it only updates outdated keys. Should be true on first initialization. + */ + fun mergeBackup(context: Context, incomingData: String, overwrite: Boolean) { + val newData = DataStore.mapper.readValue(incomingData) + if (overwrite) { + Log.d(LOG_KEY, "overwriting data") + context.restore(newData) + + return + } + + val keysToUpdate = getKeysToUpdate(context.getBackup(), newData) + if (keysToUpdate.isEmpty()) { + Log.d(LOG_KEY, "remote data is up to date, sync not needed") + return + } + + + Log.d(LOG_KEY, incomingData) + context.restore(newData, keysToUpdate) } + private fun getKeysToUpdate( + currentData: BackupUtils.BackupFile, + newData: BackupUtils.BackupFile + ): Set { + val currentSync = getSyncKeys(currentData) + val newSync = getSyncKeys(newData) - Log.d(LOG_KEY, incomingData) - restore(newData, keysToUpdate) - } + val changedKeys = newSync.filter { + val localTimestamp = currentSync[it.key] ?: 0L + it.value > localTimestamp + }.keys - var willQueueSoon: Boolean? - var uploadJob: Job? - fun shouldUpdate(changedKey: String, isSettings: Boolean): Boolean - fun addToQueue(changedKey: String, isSettings: Boolean) { + val onlyLocalKeys = currentSync.keys.filter { !newSync.containsKey(it) } + val missingKeys = getAllMissingKeys(currentData, newData) - if (!shouldUpdate(changedKey, isSettings)) { - willQueueSoon = false - Log.d(LOG_KEY, "upload not required, data is same") - return + return (missingKeys + onlyLocalKeys + changedKeys).toSet() } - addToQueueNow() - } - fun addToQueueNow() { - if (uploadJob != null && uploadJob!!.isActive) { - Log.d(LOG_KEY, "upload is canceled, scheduling new") - uploadJob?.cancel() - } + private fun getAllMissingKeys( + old: BackupUtils.BackupFile, + new: BackupUtils.BackupFile + ): List = BackupUtils.RestoreSource + .values() + .filter { it != BackupUtils.RestoreSource.SYNC } + .fold(mutableListOf()) { acc, source -> + acc.addAll(getMissingKeysPrefixed(source, old, new)) + acc + } - uploadJob = ioScope.launchSafe { - willQueueSoon = false - Log.d(LOG_KEY, "upload is running now") - uploadSyncData() - } - } + private fun getMissingKeysPrefixed( + restoreSource: BackupUtils.RestoreSource, + old: BackupUtils.BackupFile, + new: BackupUtils.BackupFile + ): List { + val oldSource = old.getData(restoreSource) + val newSource = new.getData(restoreSource) + val prefixToMatch = restoreSource.syncPrefix - fun compareJson(old: String, new: String): JSONComparison { - var result: JSONCompareResult? - - val executionTime = measureTimeMillis { - result = try { - JSONCompare.compareJSON(old, new, JSONCompareMode.NON_EXTENSIBLE) - } catch (e: Exception) { - null + return listOf( + *getMissing(oldSource._Bool, newSource._Bool), + *getMissing(oldSource._Long, newSource._Long), + *getMissing(oldSource._Float, newSource._Float), + *getMissing(oldSource._Int, newSource._Int), + *getMissing(oldSource._String, newSource._String), + *getMissing(oldSource._StringSet, newSource._StringSet), + ).map { + prefixToMatch + it } } - val failed = result?.failed() ?: true - Log.d(LOG_KEY, "JSON comparison took $executionTime ms, compareFailed=$failed, result=$result") - return JSONComparison(failed, result) + private fun getMissing(old: Map?, new: Map?): Array = + (new.orEmpty().keys - old.orEmpty().keys) + .toTypedArray() } - - fun getKeysToUpdate( - currentData: BackupUtils.BackupFile, - newData: BackupUtils.BackupFile - ): Set { - val currentSync = getSyncKeys(currentData) - val newSync = getSyncKeys(newData) - - val changedKeys = newSync.filter { - val localTimestamp = currentSync[it.key] ?: 0L - it.value > localTimestamp - }.keys - - val onlyLocalKeys = currentSync.keys.filter { !newSync.containsKey(it) } - val missingKeys = getAllMissingKeys(currentData, newData) - - return (missingKeys + onlyLocalKeys + changedKeys).toSet() - } - - private fun getSyncKeys(data: BackupUtils.BackupFile) = - data.syncMeta._Long.orEmpty().filter { it.key.startsWith(SYNC_HISTORY_PREFIX) } - - - private fun getAllMissingKeys( - old: BackupUtils.BackupFile, - new: BackupUtils.BackupFile - ): List = BackupUtils.RestoreSource - .values() - .filter { it != BackupUtils.RestoreSource.SYNC } - .fold(mutableListOf()) { acc, source -> - acc.addAll(getMissingKeysPrefixed(source, old, new)) - acc - } - - private fun getMissingKeysPrefixed( - restoreSource: BackupUtils.RestoreSource, - old: BackupUtils.BackupFile, - new: BackupUtils.BackupFile - ): List { - val oldSource = old.getData(restoreSource) - val newSource = new.getData(restoreSource) - val prefixToMatch = restoreSource.syncPrefix - - return listOf( - *getMissing(oldSource._Bool, newSource._Bool), - *getMissing(oldSource._Long, newSource._Long), - *getMissing(oldSource._Float, newSource._Float), - *getMissing(oldSource._Int, newSource._Int), - *getMissing(oldSource._String, newSource._String), - *getMissing(oldSource._StringSet, newSource._StringSet), - ).map { - prefixToMatch + it - } - } - - - private fun getMissing(old: Map?, new: Map?): Array = - (new.orEmpty().keys - old.orEmpty().keys) - .toTypedArray() - } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt index e17e7ccc..ec0ca6ee 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt @@ -1,9 +1,7 @@ package com.lagradost.cloudstream3.syncproviders -import androidx.annotation.WorkerThread - interface InAppAuthAPI : AuthAPI { - data class LoginData( + data class UserData( val username: String? = null, val password: String? = null, val server: String? = null, @@ -21,10 +19,10 @@ interface InAppAuthAPI : AuthAPI { val storesPasswordInPlainText: Boolean // return true if logged in successfully - suspend fun login(data: LoginData): Boolean + suspend fun login(data: UserData): Boolean // used to fill the UI if you want to edit any data about your login info - fun getLatestLoginData(): LoginData? + fun getUserData(): UserData? } abstract class InAppAuthAPIManager(defIndex: Int) : AccountManager(defIndex), InAppAuthAPI { @@ -47,11 +45,11 @@ abstract class InAppAuthAPIManager(defIndex: Int) : AccountManager(defIndex), In override val icon: Int? = null - override suspend fun login(data: InAppAuthAPI.LoginData): Boolean { + override suspend fun login(data: InAppAuthAPI.UserData): Boolean { throw NotImplementedError() } - override fun getLatestLoginData(): InAppAuthAPI.LoginData? { + override fun getUserData(): InAppAuthAPI.UserData? { throw NotImplementedError() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt index 3788e26a..9eccfc52 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt @@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.syncproviders import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonIgnore -import com.lagradost.cloudstream3.AcraApplication interface InAppOAuth2API : OAuth2API { data class LoginData( @@ -31,36 +30,4 @@ interface InAppOAuth2API : OAuth2API { // used to fill the UI if you want to edit any data about your login info fun getLatestLoginData(): LoginData? -} - -abstract class InAppOAuth2APIManager(defIndex: Int) : AccountManager(defIndex), InAppOAuth2API { - enum class K { - LOGIN_DATA, - IS_READY, - TOKEN, - ; - - val value: String = "data_oauth2_$name" - } - - protected fun storeValue(key: K, value: T) = AcraApplication.setKey( - accountId, key.value, value - ) - - protected fun clearValue(key: K) = AcraApplication.removeKey( - accountId, key.value - ) - - protected inline fun getValue(key: K) = AcraApplication.getKey( - accountId, key.value - ) - - override val requiresLogin = true - override val createAccountUrl = null - - override fun logOut() { - K.values().forEach { clearValue(it) } - removeAccountKeys() - } - } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index 573ed6f0..f0601be1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -19,20 +19,16 @@ import com.google.api.services.drive.DriveScopes import com.google.api.services.drive.model.File import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.BackupAPI -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.LOG_KEY import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API -import com.lagradost.cloudstream3.syncproviders.InAppOAuth2APIManager -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.BackupUtils.getBackup +import com.lagradost.cloudstream3.syncproviders.RemoteFile import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe -import com.lagradost.cloudstream3.utils.Scheduler -import kotlinx.coroutines.Job import kotlinx.coroutines.runBlocking import java.io.InputStream import java.util.Date @@ -43,10 +39,12 @@ import java.util.Date * * | State | Priority | Description * |---------:|:--------:|--------------------------------------------------------------------- - * | Someday | 4 | Add button to manually trigger sync * | Someday | 5 | Choose what should be synced and recheck `invalidKeys` in createBackupScheduler * | Someday | 3 | Add option to use proper OAuth through Google Services One Tap * | Someday | 5 | Encrypt data on Drive (low priority) + * | Someday | 4 | Make local sync + * | Someday | 4 | Make sync button more interactive + * | Solved | 4 | Add button to manually trigger sync * | Solved | 1 | Racing conditions when multiple devices in use * | Solved | 2 | Restoring backup should update view models * | Solved | 1 | Check if data was really changed when calling backupscheduler.work then @@ -59,11 +57,7 @@ import java.util.Date * | Solved | 3 | Move "https://chiff.github.io/cloudstream-sync/google-drive" */ class GoogleDriveApi(index: Int) : - InAppOAuth2APIManager(index), - BackupAPI { - ///////////////////////////////////////// - ///////////////////////////////////////// - // Setup + BackupAPI(index), InAppOAuth2API { override val key = "gdrive" override val redirectUrl = "oauth/google-drive" @@ -71,6 +65,8 @@ class GoogleDriveApi(index: Int) : override val name = "Google Drive" override val icon = R.drawable.ic_baseline_add_to_drive_24 + override val requiresLogin = true + override val createAccountUrl = null override val requiresFilename = true override val requiresSecret = true override val requiresClientId = true @@ -79,17 +75,31 @@ class GoogleDriveApi(index: Int) : "https://recloudstream.github.io/cloudstream-sync/google-drive" override val infoUrl = "https://recloudstream.github.io/cloudstream-sync/google-drive/help.html" - override var isActive: Boolean? = false - override var willQueueSoon: Boolean? = false - override var uploadJob: Job? = null - private var tempAuthFlow: AuthorizationCodeFlow? = null - private var lastBackupJson: String? = null companion object { const val GOOGLE_ACCOUNT_INFO_KEY = "google_account_info_key" } + private fun storeValue(key: K, value: T) = setKey( + accountId, key.value, value + ) + + private fun clearValue(key: K) = removeKey(accountId, key.value) + + private inline fun getValue(key: K) = getKey( + accountId, key.value + ) + + enum class K { + LOGIN_DATA, + IS_READY, + TOKEN, + ; + + val value: String = "data_oauth2_$name" + } + ///////////////////////////////////////// ///////////////////////////////////////// // OAuth2API implementation @@ -125,24 +135,17 @@ class GoogleDriveApi(index: Int) : storeValue(K.TOKEN, googleTokenResponse) storeValue(K.IS_READY, true) - updateApiActiveState() - runDownloader(runNow = true, overwrite = true) + + // First launch overwrites + scheduleDownload(runNow = true, overwrite = true) tempAuthFlow = null return true } - ///////////////////////////////////////// - ///////////////////////////////////////// - // InAppOAuth2APIManager implementation override suspend fun initialize() { - updateApiActiveState() - if (isActive != true) { - return - } - ioSafe { - runDownloader(true) + scheduleDownload(true) } } @@ -179,7 +182,7 @@ class GoogleDriveApi(index: Int) : } override fun loginInfo(): AuthAPI.LoginInfo? { - val driveService = getDriveService() ?: return null + val driveService = getLatestLoginData()?.let { getDriveService(it) } ?: return null val userInfo = runBlocking { getUserInfo(driveService) } ?: getBlankUser() @@ -210,7 +213,6 @@ class GoogleDriveApi(index: Int) : this.tempAuthFlow = authFlow try { - updateApiActiveState() registerAccount() val url = authFlow.newAuthorizationUrl().setRedirectUri(data.redirectUrl).build() @@ -229,25 +231,68 @@ class GoogleDriveApi(index: Int) : return getValue(K.LOGIN_DATA) } + override suspend fun getLoginData(): InAppOAuth2API.LoginData? { + return getLatestLoginData() + } + ///////////////////////////////////////// ///////////////////////////////////////// // BackupAPI implementation - override fun isActive(): Boolean { + override suspend fun isReady(): Boolean { + val loginData = getLatestLoginData() return getValue(K.IS_READY) == true && loginInfo() != null && - getDriveService() != null && - AcraApplication.context != null && - getLatestLoginData() != null + loginData != null && + getDriveService(loginData) != null && + AcraApplication.context != null } - override fun Context.createBackup(loginData: InAppOAuth2API.LoginData) { - val drive = getDriveService() ?: return + override suspend fun getRemoteFile( + context: Context, + loginData: InAppOAuth2API.LoginData + ): RemoteFile { + val drive = + getDriveService(loginData) ?: return RemoteFile.Error("Cannot get drive service") + + val existingFileId = getOrFindExistingSyncFileId(drive, loginData) + val existingFile = if (existingFileId != null) { + try { + drive.files().get(existingFileId) + } catch (e: Exception) { + Log.e(LOG_KEY, "Could not find file for id $existingFileId", e) + null + } + } else { + null + } + + if (existingFile != null) { + try { + val inputStream: InputStream = existingFile.executeMediaAsInputStream() + val content: String = inputStream.bufferedReader().use { it.readText() } + Log.d(LOG_KEY, "downloadSyncData merging") + return RemoteFile.Success(content) + } catch (e: Exception) { + Log.e(LOG_KEY, "download failed", e) + } + } + + // if failed + Log.d(LOG_KEY, "downloadSyncData file not exists") + return RemoteFile.NotFound() + } + + override suspend fun uploadFile( + context: Context, + backupJson: String, + loginData: InAppOAuth2API.LoginData + ) { + val drive = getDriveService(loginData) ?: return val fileName = loginData.fileName val syncFileId = loginData.syncFileId val ioFile = java.io.File(AcraApplication.context?.cacheDir, fileName) - lastBackupJson = getBackup().toJson() - ioFile.writeText(lastBackupJson!!) + ioFile.writeText(backupJson) val fileMetadata = File() fileMetadata.name = fileName @@ -277,40 +322,6 @@ class GoogleDriveApi(index: Int) : } } - override fun downloadSyncData(overwrite: Boolean) { - val ctx = AcraApplication.context ?: return - val drive = getDriveService() ?: return - val loginData = getLatestLoginData() ?: return - - val existingFileId = getOrFindExistingSyncFileId(drive, loginData) - val existingFile = if (existingFileId != null) { - try { - drive.files().get(existingFileId) - } catch (e: Exception) { - Log.e(LOG_KEY, "Could not find file for id $existingFileId", e) - null - } - } else { - null - } - - if (existingFile != null) { - try { - val inputStream: InputStream = existingFile.executeMediaAsInputStream() - val content: String = inputStream.bufferedReader().use { it.readText() } - Log.d(LOG_KEY, "downloadSyncData merging") - ctx.mergeBackup(content, overwrite) - return - } catch (e: Exception) { - Log.e(LOG_KEY, "download failed", e) - } - } - - // if failed - Log.d(LOG_KEY, "downloadSyncData file not exists") - uploadSyncData() - } - private fun getOrFindExistingSyncFileId( drive: Drive, loginData: InAppOAuth2API.LoginData @@ -342,40 +353,8 @@ class GoogleDriveApi(index: Int) : return null } - override fun uploadSyncData() { - val canUpload = getValue(K.IS_READY) - if (canUpload != true) { - Log.d(LOG_KEY, "uploadSyncData is not ready yet") - return - } - - val ctx = AcraApplication.context - val loginData = getLatestLoginData() - - if (ctx == null) { - Log.d(LOG_KEY, "uploadSyncData cannot run (ctx)") - return - } - - - if (loginData == null) { - Log.d(LOG_KEY, "uploadSyncData cannot run (loginData)") - return - } - - Log.d(LOG_KEY, "uploadSyncData will run") - ctx.createBackup(loginData) - } - - override fun shouldUpdate(changedKey: String, isSettings: Boolean): Boolean { - val ctx = AcraApplication.context ?: return false - - val newBackup = ctx.getBackup().toJson() - return compareJson(lastBackupJson ?: "", newBackup).failed - } - - private fun getDriveService(): Drive? { - val credential = getCredentialsFromStore() ?: return null + private fun getDriveService(loginData: InAppOAuth2API.LoginData): Drive? { + val credential = getCredentialsFromStore(loginData) ?: return null return Drive.Builder( GAPI.HTTP_TRANSPORT, @@ -386,41 +365,12 @@ class GoogleDriveApi(index: Int) : .build() } - ///////////////////////////////////////// - ///////////////////////////////////////// - // Internal - private val continuousDownloader = Scheduler( - BackupAPI.DOWNLOAD_THROTTLE.inWholeMilliseconds, - onWork = { overwrite -> - if (uploadJob?.isActive == true || willQueueSoon == true) { - uploadJob!!.invokeOnCompletion { - Log.d(LOG_KEY, "upload is running, reschedule download") - runDownloader(false, overwrite == true) - } - } else { - Log.d(LOG_KEY, "downloadSyncData will run") - ioSafe { - downloadSyncData(overwrite == true) - } - runDownloader() - } - } - ) - private fun runDownloader(runNow: Boolean = false, overwrite: Boolean = false) { - if (runNow) { - continuousDownloader.workNow(overwrite) - } else { - continuousDownloader.work(overwrite) - } - } - - private fun getCredentialsFromStore(): Credential? { - val loginDate = getLatestLoginData() + private fun getCredentialsFromStore(loginData: InAppOAuth2API.LoginData): Credential? { val token = getValue(K.TOKEN) - val credential = if (loginDate != null && token != null) { - GAPI.getCredentials(token, loginDate) + val credential = if (token != null) { + GAPI.getCredentials(token, loginData) } else { return null } @@ -437,6 +387,11 @@ class GoogleDriveApi(index: Int) : return credential } + override fun logOut() { + K.values().forEach { clearValue(it) } + removeAccountKeys() + } + ///////////////////////////////////////// ///////////////////////////////////////// // Google API integration helper diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt index 4030649d..ceca952d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt @@ -90,9 +90,9 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi return null } - override fun getLatestLoginData(): InAppAuthAPI.LoginData? { + override fun getUserData(): InAppAuthAPI.UserData? { val current = getAuthKey() ?: return null - return InAppAuthAPI.LoginData(username = current.user, current.pass) + return InAppAuthAPI.UserData(username = current.user, current.pass) } /* @@ -143,7 +143,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi return false } - override suspend fun login(data: InAppAuthAPI.LoginData): Boolean { + override suspend fun login(data: InAppAuthAPI.UserData): Boolean { val username = data.username ?: throw ErrorLoadingException("Requires Username") val password = data.password ?: throw ErrorLoadingException("Requires Password") switchToNewAccount() diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/PcloudApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/PcloudApi.kt new file mode 100644 index 00000000..f7a25561 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/PcloudApi.kt @@ -0,0 +1,198 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import android.content.Context +import android.util.Base64 +import androidx.fragment.app.FragmentActivity +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.debugPrint +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.BackupAPI +import com.lagradost.cloudstream3.syncproviders.OAuth2API +import com.lagradost.cloudstream3.syncproviders.RemoteFile +import com.lagradost.cloudstream3.utils.AppUtils.splitQuery +import com.lagradost.nicehttp.NiceFile +import java.io.File +import java.net.URL +import java.security.SecureRandom + +class PcloudApi(index: Int) : OAuth2API, + BackupAPI(index) { + companion object { + const val PCLOUD_TOKEN_KEY: String = "pcloud_token" + const val PCLOUD_HOST_KEY: String = "pcloud_host" + const val PCLOUD_USERNAME_KEY: String = "pcloud_username" + const val PCLOUD_FILE_ID_KEY = "pcloud_file_id" + const val FILENAME = "cloudstream-backup.json" + +// data class OAuthResponse( +// @JsonProperty("access_token") val access_token: String, +// @JsonProperty("token_type") val token_type: String, +// @JsonProperty("uid") val uid: Int, +// +// @JsonProperty("result") val result: Int, +// @JsonProperty("error") val error: String? +// ) + + /** https://docs.pcloud.com/methods/file/uploadfile.html */ + data class FileUpload( + @JsonProperty("result") val result: Int, + // @JsonProperty("fileids") val fileids: List, + @JsonProperty("metadata") val metadata: List, + ) { + data class FileMetaData( + val fileid: Long, + ) + } + + /** https://docs.pcloud.com/methods/streaming/getfilelink.html */ + data class FileLink( + @JsonProperty("result") val result: Int, + @JsonProperty("path") val path: String, + @JsonProperty("hosts") val hosts: List + ) { + fun getBestLink(): String? { + val host = hosts.firstOrNull() ?: return null + return "https://$host$path" + } + } + + data class UserInfo( + @JsonProperty("email") val email: String, + @JsonProperty("userid") val userid: String + ) + } + + + override val name = "pCloud" + override val icon = R.drawable.ic_baseline_add_to_drive_24 + override val requiresLogin = true + override val createAccountUrl = "https://my.pcloud.com/#page=login" + override val idPrefix = "pcloud" + + override fun loginInfo(): AuthAPI.LoginInfo? { + // Guarantee token + if (getKey(accountId, PCLOUD_TOKEN_KEY).isNullOrBlank()) return null + + val username = getKey(accountId, PCLOUD_USERNAME_KEY) ?: return null + return AuthAPI.LoginInfo( + name = username, + accountIndex = accountIndex + ) + } + + override fun logOut() { + removeAccountKeys() + } + + override suspend fun initialize() { + scheduleDownload(true) + } + + val url = "https://pcloud.com/" + override val key = "" // TODO FIX + override val redirectUrl = "pcloud" + + override suspend fun handleRedirect(url: String): Boolean { + // redirect_uri#access_token=XXXXX&token_type=bearer&uid=YYYYYY&state=ZZZZZZ&locationid=[1 or 2]&hostname=[api.pcloud.com or eapi.pcloud.com] + val query = splitQuery(URL(url.replace(appString, "https").replace("#", "?"))) + + if (query["state"] != state || state.isBlank()) { + return false + } + state = "" + + val token = query["access_token"] ?: return false + val hostname = query["hostname"] ?: return false + + val userInfo = app.get( + "https://$hostname/userinfo", + headers = mapOf("Authorization" to "Bearer $token") + ).parsedSafe() ?: return false + + switchToNewAccount() + setKey(accountId, PCLOUD_TOKEN_KEY, token) + setKey(accountId, PCLOUD_USERNAME_KEY, userInfo.email.substringBeforeLast("@")) + setKey(accountId, PCLOUD_HOST_KEY, hostname) + registerAccount() + + scheduleDownload(runNow = true, overwrite = true) + return true + } + + private fun getToken(): String? { + return getKey(accountId, PCLOUD_TOKEN_KEY) + } + + private val mainUrl: String + get() = getKey(accountId, PCLOUD_HOST_KEY)?.let { "https://$it" } + ?: "https://api.pcloud.com" + private val authHeaders: Map + get() = getToken()?.let { token -> mapOf("Authorization" to "Bearer $token") } ?: mapOf() + + private fun getFileId(): Long? = getKey(accountId, PCLOUD_FILE_ID_KEY) + + private var state = "" + override fun authenticate(activity: FragmentActivity?) { + val secureRandom = SecureRandom() + val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128 + secureRandom.nextBytes(codeVerifierBytes) + state = + Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=').replace("+", "-") + .replace("/", "_").replace("\n", "") + val codeChallenge = state + + val request = + "https://my.pcloud.com/oauth2/authorize?response_type=token&client_id=$key&state=$codeChallenge&redirect_uri=$appString://$redirectUrl" + openBrowser(request, activity) + } + + override suspend fun getLoginData(): String? { + return getToken() + } + + override suspend fun uploadFile( + context: Context, + backupJson: String, + loginData: String + ) { + val ioFile = File(AcraApplication.context?.cacheDir, FILENAME) + ioFile.writeText(backupJson) + + val uploadedFile = app.post( + "$mainUrl/uploadfile", + files = listOf( + NiceFile(ioFile), + NiceFile("nopartial", "1") + ), + headers = authHeaders + ).parsedSafe() + + debugPrint { "${this.name}: Uploaded file: $uploadedFile" } + + val fileId = uploadedFile?.metadata?.firstOrNull()?.fileid ?: return + setKey(accountId, PCLOUD_FILE_ID_KEY, fileId) + } + + override suspend fun getRemoteFile( + context: Context, + loginData: String + ): RemoteFile { + val fileId = getFileId() ?: return RemoteFile.NotFound() + val fileLink = app.post( + "$mainUrl/getfilelink", data = mapOf( + "fileid" to fileId.toString() + ), + referer = "https://pcloud.com", + headers = authHeaders + ).parsedSafe() + + val url = fileLink?.getBestLink() ?: return RemoteFile.NotFound() + return RemoteFile.Success(app.get(url).text) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index f3325227..4295ff5d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -18,6 +18,7 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniList import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.googleDriveApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.pcloudApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI @@ -140,7 +141,8 @@ class SettingsAccount : PreferenceFragmentCompat() { R.string.anilist_key to aniListApi, R.string.simkl_key to simklApi, R.string.opensubtitles_key to openSubtitlesApi, - R.string.gdrive_key to googleDriveApi + R.string.gdrive_key to googleDriveApi, + R.string.pcloud_key to pcloudApi ) for ((key, api) in syncApis) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt index 4895b0d2..eb5b2401 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt @@ -9,6 +9,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView +import android.widget.Toast import androidx.annotation.StringRes import androidx.core.view.children import androidx.core.view.isVisible @@ -17,11 +18,15 @@ import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.google.android.material.appbar.MaterialToolbar +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.MainSettingsBinding import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.BackupApis import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers +import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.ui.home.HomeFragment +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.setImage @@ -206,6 +211,21 @@ class SettingsFragment : Fragment() { } } + // Only show the button if the api does not require login, requires login, but the user is logged in + forceSyncDataBtt.isVisible = BackupApis.any { api -> + api !is AuthAPI || api.loginInfo() != null + } + + forceSyncDataBtt.setOnClickListener { + BackupApis.forEach { api -> + api.scheduleUpload() + } + showToast(activity, txt(R.string.syncing_data), Toast.LENGTH_SHORT) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + forceSyncDataBtt.tooltipText = txt(R.string.sync_data).asString(forceSyncDataBtt.context) + } + // Default focus on TV if (isTrueTv) { settingsGeneral.requestFocus() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt index 52a61bf8..1917546b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt @@ -27,7 +27,7 @@ class InAppAuthDialogBuilder( override fun onLogin(dialog: AlertDialog): Unit = with(binding) { // if (activity == null) throw IllegalStateException("Login should be called after validation") - val loginData = InAppAuthAPI.LoginData( + val userData = InAppAuthAPI.UserData( username = if (api.requiresUsername) loginUsernameInput.text?.toString() else null, password = if (api.requiresPassword) loginPasswordInput.text?.toString() else null, email = if (api.requiresEmail) loginEmailInput.text?.toString() else null, @@ -36,7 +36,7 @@ class InAppAuthDialogBuilder( ioSafe { val isSuccessful = try { - api.login(loginData) + api.login(userData) } catch (e: Exception) { logError(e) false @@ -93,7 +93,7 @@ class InAppAuthDialogBuilder( override fun handleStoresPasswordInPlainText(dialog: AlertDialog): Unit = with(binding) { if (!api.storesPasswordInPlainText) return - api.getLatestLoginData()?.let { data -> + api.getUserData()?.let { data -> loginEmailInput.setText(data.email ?: "") loginServerInput.setText(data.server ?: "") loginUsernameInput.setText(data.username ?: "") 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 4e54095e..f75e8315 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -3,8 +3,6 @@ package com.lagradost.cloudstream3.utils import android.annotation.SuppressLint import android.content.Context import android.net.Uri -import android.os.Build -import android.provider.MediaStore import android.util.Log import android.widget.Toast import androidx.activity.result.ActivityResultLauncher @@ -20,11 +18,12 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.plugins.PLUGINS_KEY import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL import com.lagradost.cloudstream3.syncproviders.BackupAPI -import com.lagradost.cloudstream3.syncproviders.InAppOAuth2APIManager +import com.lagradost.cloudstream3.syncproviders.IBackupAPI import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_TOKEN_KEY import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_UNIXTIME_KEY import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_USER_KEY +import com.lagradost.cloudstream3.syncproviders.providers.GoogleDriveApi import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_REFRESH_TOKEN_KEY import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_TOKEN_KEY @@ -55,7 +54,7 @@ object BackupUtils { DATA, SETTINGS, SYNC; val prefix = "$name/" - val syncPrefix = "${BackupAPI.SYNC_HISTORY_PREFIX}$prefix" + val syncPrefix = "${IBackupAPI.SYNC_HISTORY_PREFIX}$prefix" } /** @@ -72,8 +71,8 @@ object BackupUtils { MAL_CACHED_LIST, MAL_UNIXTIME_KEY, MAL_USER_KEY, - InAppOAuth2APIManager.K.TOKEN.value, - InAppOAuth2APIManager.K.IS_READY.value, + GoogleDriveApi.K.TOKEN.value, + GoogleDriveApi.K.IS_READY.value, // The plugins themselves are not backed up PLUGINS_KEY, @@ -81,6 +80,8 @@ object BackupUtils { OPEN_SUBTITLES_USER_KEY, "nginx_user", // Nginx user key + DOWNLOAD_HEADER_CACHE, + DOWNLOAD_EPISODE_CACHE ) /** false if blacklisted key */ @@ -328,7 +329,7 @@ object BackupUtils { var prefixToRemove = prefixToMatch if (restoreSource == RestoreSource.SYNC) { - prefixToMatch = BackupAPI.SYNC_HISTORY_PREFIX + prefixToMatch = IBackupAPI.SYNC_HISTORY_PREFIX prefixToRemove = "" } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index dc9f1835..1c99f0bf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -12,7 +12,8 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.setKeyClass import com.lagradost.cloudstream3.mvvm.logError import kotlin.reflect.KClass import kotlin.reflect.KProperty -import com.lagradost.cloudstream3.syncproviders.BackupAPI +import com.lagradost.cloudstream3.syncproviders.IBackupAPI +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe const val DOWNLOAD_HEADER_CACHE = "download_header_cache" @@ -31,6 +32,7 @@ class PreferenceDelegate( val key: String, val default: T //, private val klass: KClass ) { private val klass: KClass = default::class + // simple cache to make it not get the key every time it is accessed, however this requires // that ONLY this changes the key private var cache: T? = null @@ -79,6 +81,7 @@ object DataStore { fun getFolderName(folder: String, path: String): String { return "${folder}/${path}" } + fun Context.setKeyRaw(path: String, value: T, restoreSource: BackupUtils.RestoreSource) { try { val editor = when (restoreSource) { @@ -100,7 +103,8 @@ object DataStore { logError(e) } } - fun Context.removeKeyRaw(path: String, restoreSource: BackupUtils.RestoreSource) { + + fun Context.removeKeyRaw(path: String, restoreSource: BackupUtils.RestoreSource) { try { when (restoreSource) { BackupUtils.RestoreSource.DATA -> getSharedPrefs().edit() @@ -143,15 +147,17 @@ object DataStore { editor.remove(path) editor.apply() - backupScheduler.work( - BackupAPI.PreferencesSchedulerData( - getSyncPrefs(), - path, - oldValueExists, - false, - BackupUtils.RestoreSource.DATA + ioSafe { + backupScheduler.work( + IBackupAPI.PreferencesSchedulerData( + getSyncPrefs(), + path, + oldValueExists, + false, + BackupUtils.RestoreSource.DATA + ) ) - ) + } } } catch (e: Exception) { logError(e) @@ -176,15 +182,17 @@ object DataStore { editor.putString(path, newValue) editor.apply() - backupScheduler.work( - BackupAPI.PreferencesSchedulerData( - getSyncPrefs(), - path, - oldValue, - newValue, - BackupUtils.RestoreSource.DATA + ioSafe { + backupScheduler.work( + IBackupAPI.PreferencesSchedulerData( + getSyncPrefs(), + path, + oldValue, + newValue, + BackupUtils.RestoreSource.DATA + ) ) - ) + } } catch (e: Exception) { logError(e) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt index 26f982af..3bc6ec6d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt @@ -6,17 +6,20 @@ import android.os.Looper import android.util.Log import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.BackupAPI -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.logHistoryChanged +import com.lagradost.cloudstream3.syncproviders.IBackupAPI +import com.lagradost.cloudstream3.syncproviders.IBackupAPI.Companion.logHistoryChanged import com.lagradost.cloudstream3.ui.home.HOME_BOOKMARK_VALUE_LIST import com.lagradost.cloudstream3.ui.player.PLAYBACK_SPEED_KEY import com.lagradost.cloudstream3.ui.player.RESIZE_MODE_KEY import com.lagradost.cloudstream3.utils.BackupUtils.nonTransferableKeys +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import kotlinx.coroutines.runBlocking class Scheduler( private val throttleTimeMs: Long, - private val onWork: (INPUT?) -> Unit, - private val beforeWork: ((INPUT?) -> Unit)? = null, - private val canWork: ((INPUT?) -> Boolean)? = null + private val onWork: suspend (INPUT) -> Unit, + private val beforeWork: (suspend (INPUT?) -> Unit)? = null, + private val canWork: (suspend (INPUT) -> Boolean)? = null ) { companion object { var SCHEDULER_ID = 1 @@ -28,50 +31,61 @@ class Scheduler( DOWNLOAD_HEADER_CACHE, PLAYBACK_SPEED_KEY, HOME_BOOKMARK_VALUE_LIST, - RESIZE_MODE_KEY + RESIZE_MODE_KEY, + ) + private val invalidUploadTriggerKeysRegex = listOf( + // These trigger automatically every time a show is opened, way too often. + Regex("""^\d+/$RESULT_SEASON/"""), + Regex("""^\d+/$RESULT_EPISODE/"""), + Regex("""^\d+/$RESULT_DUB/"""), ) - fun createBackupScheduler() = Scheduler>( + fun createBackupScheduler() = Scheduler>( BackupAPI.UPLOAD_THROTTLE.inWholeMilliseconds, onWork = { input -> - if (input == null) { - throw IllegalStateException() - } - - AccountManager.BackupApis.forEach { - it.addToQueue( + AccountManager.BackupApis.forEach { api -> + api.scheduleUpload( input.storeKey, input.source == BackupUtils.RestoreSource.SETTINGS ) } }, - beforeWork = { - AccountManager.BackupApis.filter { - it.isActive == true + beforeWork = { _ -> + AccountManager.BackupApis.filter { api -> + api.isReady() }.forEach { - it.willQueueSoon = true + it.willUploadSoon = true } }, canWork = { input -> - if (input == null) { - throw IllegalStateException() - } - - val hasSomeActiveManagers = AccountManager.BackupApis.any { it.isActive == true } + val hasSomeActiveManagers = AccountManager.BackupApis.any { it.isReady() } if (!hasSomeActiveManagers) { return@Scheduler false } - val hasInvalidKey = invalidUploadTriggerKeys.contains(input.storeKey) - if (hasInvalidKey) { - return@Scheduler false - } - val valueDidNotChange = input.oldValue == input.newValue if (valueDidNotChange) { return@Scheduler false } + // Do not sync account preferences + val isAccountKey = AccountManager.accountManagers.any { + input.storeKey.startsWith("${it.accountId}/") + } + if (isAccountKey) { + return@Scheduler false + } + + val hasInvalidKey = invalidUploadTriggerKeys.any { key -> + input.storeKey.startsWith(key) + } || invalidUploadTriggerKeysRegex.any { keyRegex -> + input.storeKey.contains(keyRegex) + } + + if (hasInvalidKey) { + return@Scheduler false + } + input.syncPrefs.logHistoryChanged(input.storeKey, input.source) return@Scheduler true } @@ -83,27 +97,29 @@ class Scheduler( fun SharedPreferences.attachBackupListener( source: BackupUtils.RestoreSource = BackupUtils.RestoreSource.SETTINGS, syncPrefs: SharedPreferences - ): BackupAPI.SharedPreferencesWithListener { + ): IBackupAPI.SharedPreferencesWithListener { val scheduler = createBackupScheduler() var lastValue = all registerOnSharedPreferenceChangeListener { sharedPreferences, storeKey -> - scheduler.work( - BackupAPI.PreferencesSchedulerData( - syncPrefs, - storeKey, - lastValue[storeKey], - sharedPreferences.all[storeKey], - source + ioSafe { + scheduler.work( + IBackupAPI.PreferencesSchedulerData( + syncPrefs, + storeKey, + lastValue[storeKey], + sharedPreferences.all[storeKey], + source + ) ) - ) + } lastValue = sharedPreferences.all } - return BackupAPI.SharedPreferencesWithListener(this, scheduler) + return IBackupAPI.SharedPreferencesWithListener(this, scheduler) } - fun SharedPreferences.attachBackupListener(syncPrefs: SharedPreferences): BackupAPI.SharedPreferencesWithListener { + fun SharedPreferences.attachBackupListener(syncPrefs: SharedPreferences): IBackupAPI.SharedPreferencesWithListener { return attachBackupListener(BackupUtils.RestoreSource.SETTINGS, syncPrefs) } } @@ -112,7 +128,7 @@ class Scheduler( private val handler = Handler(Looper.getMainLooper()) private var runnable: Runnable? = null - fun work(input: INPUT? = null): Boolean { + suspend fun work(input: INPUT): Boolean { if (canWork?.invoke(input) == false) { // Log.d(LOG_KEY, "[$id] cannot schedule [${input}]") return false @@ -125,7 +141,7 @@ class Scheduler( return true } - fun workNow(input: INPUT? = null): Boolean { + suspend fun workNow(input: INPUT): Boolean { if (canWork?.invoke(input) == false) { Log.d(BackupAPI.LOG_KEY, "[$id] cannot run immediate [${input}]") return false @@ -147,13 +163,20 @@ class Scheduler( } } - private fun throttle(input: INPUT?) { + /** + * Prevents spamming the service by only allowing one job every throttleTimeMs + * @see throttleTimeMs + */ + private suspend fun throttle(input: INPUT) { stop() runnable = Runnable { Log.d(BackupAPI.LOG_KEY, "[$id] schedule success") - onWork(input) + runBlocking { + onWork(input) + } + }.also { run -> + handler.postDelayed(run, throttleTimeMs) } - handler.postDelayed(runnable!!, throttleTimeMs) } } \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_cloud_24.xml b/app/src/main/res/drawable/baseline_cloud_24.xml new file mode 100644 index 00000000..b3c6d33c --- /dev/null +++ b/app/src/main/res/drawable/baseline_cloud_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/main_settings.xml b/app/src/main/res/layout/main_settings.xml index 387f98fa..5115090e 100644 --- a/app/src/main/res/layout/main_settings.xml +++ b/app/src/main/res/layout/main_settings.xml @@ -41,9 +41,10 @@ + + + mal_key opensubtitles_key gdrive_key + pcloud_key nginx_key password123 MyCoolUsername @@ -696,5 +697,7 @@ Oauth redirect url (optional) https://recloudstream.github.io/cloudstream-sync/google-drive Info + Sync data + Syncing data diff --git a/app/src/main/res/xml/settings_account.xml b/app/src/main/res/xml/settings_account.xml index e7a45f75..c798184d 100644 --- a/app/src/main/res/xml/settings_account.xml +++ b/app/src/main/res/xml/settings_account.xml @@ -17,6 +17,10 @@ + + From 9e85359ad3fc483d7e76c73956f23ae07853f416 Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Tue, 31 Oct 2023 21:36:15 +0100 Subject: [PATCH 29/37] Safer API and fixed syncing resume watching --- .../syncproviders/AccountManager.kt | 2 +- .../cloudstream3/syncproviders/BackupAPI.kt | 403 ++++++++++-------- .../syncproviders/providers/GoogleDriveApi.kt | 4 +- .../ui/settings/SettingsAccount.kt | 3 +- .../ui/settings/SettingsFragment.kt | 13 +- .../cloudstream3/utils/BackupUtils.kt | 5 +- .../lagradost/cloudstream3/utils/DataStore.kt | 6 +- .../lagradost/cloudstream3/utils/Scheduler.kt | 22 +- 8 files changed, 257 insertions(+), 201 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt index b73105af..657ce58a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -44,7 +44,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { // used for active backup val BackupApis - get() = listOf>( + get() = listOf( googleDriveApi, pcloudApi ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt index 1a292195..a01d6d2e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -7,9 +7,8 @@ import com.fasterxml.jackson.module.kotlin.readValue import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.mvvm.debugException import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.LOG_KEY -import com.lagradost.cloudstream3.syncproviders.IBackupAPI.Companion.compareJson -import com.lagradost.cloudstream3.syncproviders.IBackupAPI.Companion.mergeBackup +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.getBackup @@ -22,7 +21,7 @@ import org.skyscreamer.jsonassert.JSONCompare import org.skyscreamer.jsonassert.JSONCompareMode import org.skyscreamer.jsonassert.JSONCompareResult import kotlin.system.measureTimeMillis -import kotlin.time.Duration.Companion.seconds +import kotlin.time.Duration.Companion.minutes interface RemoteFile { class Error(val message: String? = null, val throwable: Throwable? = null) : RemoteFile @@ -30,8 +29,83 @@ interface RemoteFile { class Success(val remoteData: String) : RemoteFile } +/** + * Safe wrapper for the backup api to be used outside the class without fear + * of causing crashes. + */ +interface SafeBackupAPI { + /** + * @return true if the service is ready for uploads and downloads. + * This includes a login check. + */ + suspend fun getIsReady(): Boolean + suspend fun scheduleDownload(runNow: Boolean = false) + fun getIsLoggedIn(): Boolean + fun scheduleUpload() + fun scheduleUpload(changedKey: String, isSettings: Boolean) + + /** + * Warns the service that an upload is incoming. Used to prevent simultaneous download and upload. + */ + fun setIsUploadingSoon() +} + +/** + * Easy interface to implement remote sync by only implementing the download and upload part of the service. + * @see BackupAPI for how the methods get used + */ +interface IBackupAPI { + /** + * Gets the user login info for uploading and downloading the backup. + * If null no backup or download will be run. + */ + suspend fun getLoginData(): LOGIN_DATA? + + /** + * Additional check if the backup operation should be run. + * Return false here to deny any backup work. + */ + suspend fun isReady(): Boolean = true + + /** + * Get the backup file as a string from the remote storage. + * @see RemoteFile.Success + * @see RemoteFile.Error + * @see RemoteFile.NotFound + */ + suspend fun getRemoteFile(context: Context, loginData: LOGIN_DATA): RemoteFile + + suspend fun uploadFile( + context: Context, + backupJson: String, + loginData: LOGIN_DATA + ) +} + +/** + * Helper class for the IBackupAPI which implements a scheduler, logging and checks. + * This makes the individual backup service implementations easier and more lightweight. + */ abstract class BackupAPI(defIndex: Int) : IBackupAPI, - AccountManager(defIndex) { + AccountManager(defIndex), SafeBackupAPI { + data class JSONComparison( + val failed: Boolean, + val result: JSONCompareResult? + ) + + data class PreferencesSchedulerData( + val syncPrefs: SharedPreferences, + val storeKey: String, + val oldValue: T, + val newValue: T, + val source: BackupUtils.RestoreSource + ) + + data class SharedPreferencesWithListener( + val self: SharedPreferences, + val scheduler: Scheduler> + ) + companion object { const val LOG_KEY = "BACKUP" @@ -39,8 +113,118 @@ abstract class BackupAPI(defIndex: Int) : IBackupAPI, // cloud project per user so there is no way to hit quota. Later we should implement // some kind of adaptive throttling which will increase decrease throttle time based // on factors like: live devices, quota limits, etc - val UPLOAD_THROTTLE = 30.seconds - val DOWNLOAD_THROTTLE = 120.seconds + val UPLOAD_THROTTLE = 5.minutes + val DOWNLOAD_THROTTLE = 5.minutes + + const val SYNC_HISTORY_PREFIX = "_hs/" + + fun SharedPreferences.logHistoryChanged( + path: String, + source: BackupUtils.RestoreSource + ) { + edit().putLong("${source.syncPrefix}$path", System.currentTimeMillis()).apply() + } + + fun compareJson(old: String, new: String): JSONComparison { + var result: JSONCompareResult? + + val executionTime = measureTimeMillis { + result = try { + JSONCompare.compareJSON(old, new, JSONCompareMode.NON_EXTENSIBLE) + } catch (e: Exception) { + null + } + } + + val failed = result?.failed() ?: true + Log.d( + LOG_KEY, + "JSON comparison took $executionTime ms, compareFailed=$failed, result=$result" + ) + + return JSONComparison(failed, result) + } + + private fun getSyncKeys(data: BackupUtils.BackupFile) = + data.syncMeta._Long.orEmpty().filter { it.key.startsWith(SYNC_HISTORY_PREFIX) } + + /** + * Merges the backup data with the app data. + * @param overwrite if true it overwrites all data same as restoring from a backup. + * if false it only updates outdated keys. Should be true on first initialization. + */ + private fun mergeBackup(context: Context, incomingData: String, overwrite: Boolean) { + val newData = DataStore.mapper.readValue(incomingData) + if (overwrite) { + Log.d(LOG_KEY, "overwriting data") + context.restore(newData) + + return + } + + val keysToUpdate = getKeysToUpdate(context.getBackup(), newData) + if (keysToUpdate.isEmpty()) { + Log.d(LOG_KEY, "remote data is up to date, sync not needed") + return + } + + Log.d(LOG_KEY, incomingData) + context.restore(newData, keysToUpdate) + } + + private fun getKeysToUpdate( + currentData: BackupUtils.BackupFile, + newData: BackupUtils.BackupFile + ): Set { + val currentSync = getSyncKeys(currentData) + val newSync = getSyncKeys(newData) + + val changedKeys = newSync.filter { + val localTimestamp = currentSync[it.key] ?: 0L + it.value > localTimestamp + }.keys + + val onlyLocalKeys = currentSync.keys.filter { !newSync.containsKey(it) } + val missingKeys = getAllMissingKeys(currentData, newData) + + return (missingKeys + onlyLocalKeys + changedKeys).toSet() + } + + private fun getAllMissingKeys( + old: BackupUtils.BackupFile, + new: BackupUtils.BackupFile + ): List = BackupUtils.RestoreSource + .values() + .filter { it != BackupUtils.RestoreSource.SYNC } + .fold(mutableListOf()) { acc, source -> + acc.addAll(getMissingKeysPrefixed(source, old, new)) + acc + } + + private fun getMissingKeysPrefixed( + restoreSource: BackupUtils.RestoreSource, + old: BackupUtils.BackupFile, + new: BackupUtils.BackupFile + ): List { + val oldSource = old.getData(restoreSource) + val newSource = new.getData(restoreSource) + val prefixToMatch = restoreSource.syncPrefix + + return listOf( + *getMissing(oldSource._Bool, newSource._Bool), + *getMissing(oldSource._Long, newSource._Long), + *getMissing(oldSource._Float, newSource._Float), + *getMissing(oldSource._Int, newSource._Int), + *getMissing(oldSource._String, newSource._String), + *getMissing(oldSource._StringSet, newSource._StringSet), + ).map { + prefixToMatch + it + } + } + + + private fun getMissing(old: Map?, new: Map?): Array = + (new.orEmpty().keys - old.orEmpty().keys).toTypedArray() } /** @@ -77,7 +261,7 @@ abstract class BackupAPI(defIndex: Int) : IBackupAPI, } } - var willUploadSoon: Boolean? = null + private var willUploadSoon: Boolean? = null private var uploadJob: Job? = null private fun shouldUploadBackup(): Boolean { @@ -87,19 +271,23 @@ abstract class BackupAPI(defIndex: Int) : IBackupAPI, return compareJson(lastBackupJson ?: "", newBackup).failed } - fun scheduleUpload() { - if (!shouldUploadBackup()) { - willUploadSoon = false - Log.d(LOG_KEY, "${this.name}: upload not required, data is same") - return - } + override fun scheduleUpload() { + normalSafeApiCall { + if (!shouldUploadBackup()) { + willUploadSoon = false + Log.d(LOG_KEY, "${this.name}: upload not required, data is same") + return@normalSafeApiCall + } - upload() + upload() + } } // changedKey and isSettings is currently unused, might be useful for more efficient update checker. - fun scheduleUpload(changedKey: String, isSettings: Boolean) { - scheduleUpload() + override fun scheduleUpload(changedKey: String, isSettings: Boolean) { + normalSafeApiCall { + scheduleUpload() + } } private fun upload() { @@ -170,7 +358,10 @@ abstract class BackupAPI(defIndex: Int) : IBackupAPI, } is RemoteFile.Error -> { - Log.d(LOG_KEY, "${this.name}: getRemoteFile failed with message: ${remoteFile.message}.") + Log.d( + LOG_KEY, + "${this.name}: getRemoteFile failed with message: ${remoteFile.message}." + ) remoteFile.throwable?.let { error -> logError(error) } null } @@ -195,162 +386,26 @@ abstract class BackupAPI(defIndex: Int) : IBackupAPI, lastBackupJson = remoteData mergeBackup(context, remoteData, overwrite) } -} - -interface IBackupAPI { - data class JSONComparison( - val failed: Boolean, - val result: JSONCompareResult? - ) - - data class PreferencesSchedulerData( - val syncPrefs: SharedPreferences, - val storeKey: String, - val oldValue: T, - val newValue: T, - val source: BackupUtils.RestoreSource - ) - - data class SharedPreferencesWithListener( - val self: SharedPreferences, - val scheduler: Scheduler> - ) - - /** - * Gets the user login info for uploading and downloading the backup. - * If null no backup or download will be run. - */ - suspend fun getLoginData(): LOGIN_DATA? - - /** - * Additional check if the backup operation should be run. - * Return false here to deny any backup work. - */ - suspend fun isReady(): Boolean = true - - /** - * Get the backup file as a string from the remote storage. - * @see RemoteFile.Success - * @see RemoteFile.Error - * @see RemoteFile.NotFound - */ - suspend fun getRemoteFile(context: Context, loginData: LOGIN_DATA): RemoteFile - - suspend fun uploadFile( - context: Context, - backupJson: String, - loginData: LOGIN_DATA - ) - - companion object { - const val SYNC_HISTORY_PREFIX = "_hs/" - - fun SharedPreferences.logHistoryChanged(path: String, source: BackupUtils.RestoreSource) { - edit().putLong("${source.syncPrefix}$path", System.currentTimeMillis()) - .apply() - } - - fun compareJson(old: String, new: String): JSONComparison { - var result: JSONCompareResult? - - val executionTime = measureTimeMillis { - result = try { - JSONCompare.compareJSON(old, new, JSONCompareMode.NON_EXTENSIBLE) - } catch (e: Exception) { - null - } - } - - val failed = result?.failed() ?: true - Log.d( - LOG_KEY, - "JSON comparison took $executionTime ms, compareFailed=$failed, result=$result" - ) - - return JSONComparison(failed, result) - } - - private fun getSyncKeys(data: BackupUtils.BackupFile) = - data.syncMeta._Long.orEmpty().filter { it.key.startsWith(SYNC_HISTORY_PREFIX) } - - /** - * Merges the backup data with the app data. - * @param overwrite if true it overwrites all data same as restoring from a backup. - * if false it only updates outdated keys. Should be true on first initialization. - */ - fun mergeBackup(context: Context, incomingData: String, overwrite: Boolean) { - val newData = DataStore.mapper.readValue(incomingData) - if (overwrite) { - Log.d(LOG_KEY, "overwriting data") - context.restore(newData) - - return - } - - val keysToUpdate = getKeysToUpdate(context.getBackup(), newData) - if (keysToUpdate.isEmpty()) { - Log.d(LOG_KEY, "remote data is up to date, sync not needed") - return - } - Log.d(LOG_KEY, incomingData) - context.restore(newData, keysToUpdate) - } - - private fun getKeysToUpdate( - currentData: BackupUtils.BackupFile, - newData: BackupUtils.BackupFile - ): Set { - val currentSync = getSyncKeys(currentData) - val newSync = getSyncKeys(newData) - - val changedKeys = newSync.filter { - val localTimestamp = currentSync[it.key] ?: 0L - it.value > localTimestamp - }.keys - - val onlyLocalKeys = currentSync.keys.filter { !newSync.containsKey(it) } - val missingKeys = getAllMissingKeys(currentData, newData) - - return (missingKeys + onlyLocalKeys + changedKeys).toSet() - } - - private fun getAllMissingKeys( - old: BackupUtils.BackupFile, - new: BackupUtils.BackupFile - ): List = BackupUtils.RestoreSource - .values() - .filter { it != BackupUtils.RestoreSource.SYNC } - .fold(mutableListOf()) { acc, source -> - acc.addAll(getMissingKeysPrefixed(source, old, new)) - acc - } - - private fun getMissingKeysPrefixed( - restoreSource: BackupUtils.RestoreSource, - old: BackupUtils.BackupFile, - new: BackupUtils.BackupFile - ): List { - val oldSource = old.getData(restoreSource) - val newSource = new.getData(restoreSource) - val prefixToMatch = restoreSource.syncPrefix - - return listOf( - *getMissing(oldSource._Bool, newSource._Bool), - *getMissing(oldSource._Long, newSource._Long), - *getMissing(oldSource._Float, newSource._Float), - *getMissing(oldSource._Int, newSource._Int), - *getMissing(oldSource._String, newSource._String), - *getMissing(oldSource._StringSet, newSource._StringSet), - ).map { - prefixToMatch + it - } - } - - - private fun getMissing(old: Map?, new: Map?): Array = - (new.orEmpty().keys - old.orEmpty().keys) - .toTypedArray() + // ------ SafeBackupAPI wrappers ------ + override suspend fun getIsReady(): Boolean { + return suspendSafeApiCall { + isReady() + } ?: false } -} + + override fun getIsLoggedIn(): Boolean { + return normalSafeApiCall { loginInfo() } != null + } + + override fun setIsUploadingSoon() { + willUploadSoon = true + } + + override suspend fun scheduleDownload(runNow: Boolean) { + suspendSafeApiCall { + scheduleDownload(runNow, false) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index f0601be1..2ca37ed2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -85,9 +85,9 @@ class GoogleDriveApi(index: Int) : accountId, key.value, value ) - private fun clearValue(key: K) = removeKey(accountId, key.value) + private fun clearValue(key: K) = removeKey(accountId, key.value) - private inline fun getValue(key: K) = getKey( + private inline fun getValue(key: K) = getKey( accountId, key.value ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 4295ff5d..4581dcb2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -13,6 +13,7 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AccountManagmentBinding import com.lagradost.cloudstream3.databinding.AccountSwitchBinding import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.googleDriveApi @@ -150,7 +151,7 @@ class SettingsAccount : PreferenceFragmentCompat() { title = getString(R.string.login_format).format(api.name, getString(R.string.account)) setOnPreferenceClickListener { - val info = api.loginInfo() + val info = normalSafeApiCall { api.loginInfo() } if (info != null) { showLoginInfo(activity, api, info) } else { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt index eb5b2401..6a5a2f62 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt @@ -24,9 +24,9 @@ import com.lagradost.cloudstream3.databinding.MainSettingsBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.BackupApis import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers -import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.ui.home.HomeFragment import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.setImage @@ -62,7 +62,8 @@ class SettingsFragment : Fragment() { fun Fragment?.setUpToolbar(title: String) { if (this == null) return - val settingsToolbar = view?.findViewById(R.id.settings_toolbar) ?: return + val settingsToolbar = + view?.findViewById(R.id.settings_toolbar) ?: return settingsToolbar.apply { setTitle(title) @@ -76,7 +77,8 @@ class SettingsFragment : Fragment() { fun Fragment?.setUpToolbar(@StringRes title: Int) { if (this == null) return - val settingsToolbar = view?.findViewById(R.id.settings_toolbar) ?: return + val settingsToolbar = + view?.findViewById(R.id.settings_toolbar) ?: return settingsToolbar.apply { setTitle(title) @@ -213,7 +215,7 @@ class SettingsFragment : Fragment() { // Only show the button if the api does not require login, requires login, but the user is logged in forceSyncDataBtt.isVisible = BackupApis.any { api -> - api !is AuthAPI || api.loginInfo() != null + api.getIsLoggedIn() } forceSyncDataBtt.setOnClickListener { @@ -223,7 +225,8 @@ class SettingsFragment : Fragment() { showToast(activity, txt(R.string.syncing_data), Toast.LENGTH_SHORT) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - forceSyncDataBtt.tooltipText = txt(R.string.sync_data).asString(forceSyncDataBtt.context) + forceSyncDataBtt.tooltipText = + txt(R.string.sync_data).asString(forceSyncDataBtt.context) } // Default focus on TV 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 f75e8315..eea106c7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -54,7 +54,7 @@ object BackupUtils { DATA, SETTINGS, SYNC; val prefix = "$name/" - val syncPrefix = "${IBackupAPI.SYNC_HISTORY_PREFIX}$prefix" + val syncPrefix = "${BackupAPI.SYNC_HISTORY_PREFIX}$prefix" } /** @@ -80,7 +80,6 @@ object BackupUtils { OPEN_SUBTITLES_USER_KEY, "nginx_user", // Nginx user key - DOWNLOAD_HEADER_CACHE, DOWNLOAD_EPISODE_CACHE ) @@ -329,7 +328,7 @@ object BackupUtils { var prefixToRemove = prefixToMatch if (restoreSource == RestoreSource.SYNC) { - prefixToMatch = IBackupAPI.SYNC_HISTORY_PREFIX + prefixToMatch = BackupAPI.SYNC_HISTORY_PREFIX prefixToRemove = "" } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index 1c99f0bf..66a62034 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -10,9 +10,9 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.getKeyClass import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKeyClass import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.BackupAPI import kotlin.reflect.KClass import kotlin.reflect.KProperty -import com.lagradost.cloudstream3.syncproviders.IBackupAPI import com.lagradost.cloudstream3.utils.Coroutines.ioSafe const val DOWNLOAD_HEADER_CACHE = "download_header_cache" @@ -149,7 +149,7 @@ object DataStore { ioSafe { backupScheduler.work( - IBackupAPI.PreferencesSchedulerData( + BackupAPI.PreferencesSchedulerData( getSyncPrefs(), path, oldValueExists, @@ -184,7 +184,7 @@ object DataStore { ioSafe { backupScheduler.work( - IBackupAPI.PreferencesSchedulerData( + BackupAPI.PreferencesSchedulerData( getSyncPrefs(), path, oldValue, diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt index 3bc6ec6d..319f09cc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt @@ -6,14 +6,12 @@ import android.os.Looper import android.util.Log import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.BackupAPI -import com.lagradost.cloudstream3.syncproviders.IBackupAPI -import com.lagradost.cloudstream3.syncproviders.IBackupAPI.Companion.logHistoryChanged +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.logHistoryChanged import com.lagradost.cloudstream3.ui.home.HOME_BOOKMARK_VALUE_LIST import com.lagradost.cloudstream3.ui.player.PLAYBACK_SPEED_KEY import com.lagradost.cloudstream3.ui.player.RESIZE_MODE_KEY import com.lagradost.cloudstream3.utils.BackupUtils.nonTransferableKeys import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import kotlinx.coroutines.runBlocking class Scheduler( private val throttleTimeMs: Long, @@ -40,7 +38,7 @@ class Scheduler( Regex("""^\d+/$RESULT_DUB/"""), ) - fun createBackupScheduler() = Scheduler>( + fun createBackupScheduler() = Scheduler>( BackupAPI.UPLOAD_THROTTLE.inWholeMilliseconds, onWork = { input -> AccountManager.BackupApis.forEach { api -> @@ -52,13 +50,13 @@ class Scheduler( }, beforeWork = { _ -> AccountManager.BackupApis.filter { api -> - api.isReady() + api.getIsReady() }.forEach { - it.willUploadSoon = true + it.setIsUploadingSoon() } }, canWork = { input -> - val hasSomeActiveManagers = AccountManager.BackupApis.any { it.isReady() } + val hasSomeActiveManagers = AccountManager.BackupApis.any { it.getIsReady() } if (!hasSomeActiveManagers) { return@Scheduler false } @@ -97,14 +95,14 @@ class Scheduler( fun SharedPreferences.attachBackupListener( source: BackupUtils.RestoreSource = BackupUtils.RestoreSource.SETTINGS, syncPrefs: SharedPreferences - ): IBackupAPI.SharedPreferencesWithListener { + ): BackupAPI.SharedPreferencesWithListener { val scheduler = createBackupScheduler() var lastValue = all registerOnSharedPreferenceChangeListener { sharedPreferences, storeKey -> ioSafe { scheduler.work( - IBackupAPI.PreferencesSchedulerData( + BackupAPI.PreferencesSchedulerData( syncPrefs, storeKey, lastValue[storeKey], @@ -116,10 +114,10 @@ class Scheduler( lastValue = sharedPreferences.all } - return IBackupAPI.SharedPreferencesWithListener(this, scheduler) + return BackupAPI.SharedPreferencesWithListener(this, scheduler) } - fun SharedPreferences.attachBackupListener(syncPrefs: SharedPreferences): IBackupAPI.SharedPreferencesWithListener { + fun SharedPreferences.attachBackupListener(syncPrefs: SharedPreferences): BackupAPI.SharedPreferencesWithListener { return attachBackupListener(BackupUtils.RestoreSource.SETTINGS, syncPrefs) } } @@ -172,7 +170,7 @@ class Scheduler( runnable = Runnable { Log.d(BackupAPI.LOG_KEY, "[$id] schedule success") - runBlocking { + ioSafe { onWork(input) } }.also { run -> From 09f0a1b0b6dce87f5d7195055e90d1874ec79239 Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Tue, 31 Oct 2023 22:03:51 +0100 Subject: [PATCH 30/37] Removed androidx.browser --- app/build.gradle.kts | 1 - .../syncproviders/providers/GoogleDriveApi.kt | 14 ++++++-------- .../settings/account/InAppOAuth2DialogBuilder.kt | 6 ++---- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 89aa93c4..bce60cfa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -283,7 +283,6 @@ dependencies { implementation("androidx.palette:palette-ktx:1.0.0") implementation("org.skyscreamer:jsonassert:1.2.3") - implementation("androidx.browser:browser:1.4.0") implementation("com.google.api-client:google-api-client:2.0.0") { exclude( group = "org.apache.httpcomponents", diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index 2ca37ed2..1f8f983e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.syncproviders.providers import android.content.Context import android.net.Uri import android.util.Log -import androidx.browser.customtabs.CustomTabsIntent import androidx.fragment.app.FragmentActivity import com.google.api.client.auth.oauth2.AuthorizationCodeFlow import com.google.api.client.auth.oauth2.Credential @@ -19,6 +18,7 @@ import com.google.api.services.drive.DriveScopes import com.google.api.services.drive.model.File import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity @@ -133,6 +133,7 @@ class GoogleDriveApi(index: Int) : data.clientId ) + registerAccount() storeValue(K.TOKEN, googleTokenResponse) storeValue(K.IS_READY, true) @@ -202,7 +203,8 @@ class GoogleDriveApi(index: Int) : data: InAppOAuth2API.LoginData ) { val credential = loginInfo() - if (credential != null) { + // Repeated attempts will not switch account because IS_READY is false + if (credential != null && getValue(K.IS_READY) != false) { switchToNewAccount() } @@ -213,13 +215,9 @@ class GoogleDriveApi(index: Int) : this.tempAuthFlow = authFlow try { - registerAccount() - val url = authFlow.newAuthorizationUrl().setRedirectUri(data.redirectUrl).build() - val customTabIntent = CustomTabsIntent.Builder().setShowTitle(true).build() - customTabIntent.launchUrl(activity, Uri.parse(url)) - } catch (e: Exception) { - switchToOldAccount() + openBrowser(url) + } catch (e: Throwable) { CommonActivity.showToast( activity, activity.getString(R.string.authenticated_user_fail).format(name) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt index 9abe0eb9..85ae3db1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt @@ -1,12 +1,11 @@ package com.lagradost.cloudstream3.ui.settings.helpers.settings.account -import android.net.Uri import android.view.View import androidx.appcompat.app.AlertDialog -import androidx.browser.customtabs.CustomTabsIntent import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AddAccountInputOauthBinding import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API @@ -40,8 +39,7 @@ class InAppOAuth2DialogBuilder( infoButton.isGone = api.infoUrl.isNullOrBlank() infoButton.setOnClickListener { - val customTabIntent = CustomTabsIntent.Builder().setShowTitle(true).build() - customTabIntent.launchUrl(binding.root.context, Uri.parse(api.infoUrl)) + api.infoUrl?.let { url -> openBrowser(url) } } } From 6fc45f61802d6eea1799f5c6ee6686378361ba41 Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Tue, 31 Oct 2023 22:33:01 +0100 Subject: [PATCH 31/37] Fix merge issues --- .../cloudstream3/syncproviders/BackupAPI.kt | 6 +- .../cloudstream3/ui/home/HomeFragment.kt | 61 ------------------- .../ui/library/LibraryFragment.kt | 13 ++-- .../cloudstream3/utils/BackupUtils.kt | 16 +++-- .../lagradost/cloudstream3/utils/Scheduler.kt | 7 +-- app/src/main/res/values/strings.xml | 4 -- 6 files changed, 22 insertions(+), 85 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt index a01d6d2e..1891db2e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -162,7 +162,7 @@ abstract class BackupAPI(defIndex: Int) : IBackupAPI, return } - val keysToUpdate = getKeysToUpdate(context.getBackup(), newData) + val keysToUpdate = getKeysToUpdate(getBackup(context), newData) if (keysToUpdate.isEmpty()) { Log.d(LOG_KEY, "remote data is up to date, sync not needed") return @@ -267,7 +267,7 @@ abstract class BackupAPI(defIndex: Int) : IBackupAPI, private fun shouldUploadBackup(): Boolean { val ctx = AcraApplication.context ?: return false - val newBackup = ctx.getBackup().toJson() + val newBackup = getBackup(ctx).toJson() return compareJson(lastBackupJson ?: "", newBackup).failed } @@ -322,7 +322,7 @@ abstract class BackupAPI(defIndex: Int) : IBackupAPI, return } - val backupFile = context.getBackup().toJson() + val backupFile = getBackup(context).toJson() lastBackupJson = backupFile Log.d(LOG_KEY, "${this.name}: uploadFile is now running") uploadFile(context, backupFile, loginData) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index 541b128f..4d940123 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -28,10 +28,6 @@ import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.MainActivity.Companion.afterBackupRestoreEvent -import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent -import com.lagradost.cloudstream3.MainActivity.Companion.bookmarksUpdatedEvent -import com.lagradost.cloudstream3.MainActivity.Companion.mainPluginsLoadedEvent import com.lagradost.cloudstream3.databinding.FragmentHomeBinding import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding @@ -42,7 +38,6 @@ import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi -import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.search.* import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback @@ -55,7 +50,6 @@ import com.lagradost.cloudstream3.utils.AppUtils.ownShow import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.DataStoreHelper.currentHomePage import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe @@ -485,61 +479,6 @@ class HomeFragment : Fragment() { fixGrid() } - fun bookmarksUpdated(_data : Boolean) { - reloadStored() - } - - override fun onResume() { - super.onResume() - reloadStored() - bookmarksUpdatedEvent += ::bookmarksUpdated - afterPluginsLoadedEvent += ::afterPluginsLoaded - mainPluginsLoadedEvent += ::afterMainPluginsLoaded - afterBackupRestoreEvent += ::reloadStored - } - - override fun onStop() { - bookmarksUpdatedEvent -= ::bookmarksUpdated - afterPluginsLoadedEvent -= ::afterPluginsLoaded - mainPluginsLoadedEvent -= ::afterMainPluginsLoaded - afterBackupRestoreEvent -= ::reloadStored - super.onStop() - } - - private fun reloadStored(unused: Unit = Unit) { - homeViewModel.reloadStored() - val list = EnumSet.noneOf(WatchType::class.java) - getKey(HOME_BOOKMARK_VALUE_LIST)?.map { WatchType.fromInternalId(it) }?.let { - list.addAll(it) - } - homeViewModel.loadStoredData(list) - } - - private fun afterMainPluginsLoaded(unused: Boolean = false) { - loadHomePage(false) - } - - private fun afterPluginsLoaded(forceReload: Boolean) { - loadHomePage(forceReload) - } - - private fun loadHomePage(forceReload: Boolean) { - val apiName = currentHomePage - - if (homeViewModel.apiName.value != apiName || apiName == null || forceReload) { - //println("Caught home: " + homeViewModel.apiName.value + " at " + apiName) - homeViewModel.loadAndCancel(apiName, forceReload) - } - } - - private fun homeHandleSearch(callback: SearchClickCallback) { - if (callback.action == SEARCH_ACTION_FOCUSED) { - //focusCallback(callback.card) - } else { - handleSearchClickCallback(callback) - } - } - private var currentApiName: String? = null private var toggleRandomButton = false diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt index 85e7468a..25d21b1a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt @@ -145,7 +145,8 @@ class LibraryFragment : Fragment() { } // Set the color for the search exit icon to the correct theme text color - val searchExitIcon = binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) + val searchExitIcon = + binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) val searchExitIconColor = TypedValue() activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true) @@ -481,6 +482,7 @@ class LibraryFragment : Fragment() { } }) } + override fun onConfigurationChanged(newConfig: Configuration) { (binding?.viewpager?.adapter as? ViewpagerAdapter)?.rebind() super.onConfigurationChanged(newConfig) @@ -499,6 +501,7 @@ class LibraryFragment : Fragment() { private fun onNewSyncData(unused: Unit) { Log.d(BackupAPI.LOG_KEY, "will reload pages") libraryViewModel.reloadPages(true) + } private val sortChangeClickListener = View.OnClickListener { view -> val methods = libraryViewModel.sortingMethods.map { @@ -515,10 +518,10 @@ class LibraryFragment : Fragment() { libraryViewModel.sort(method) }) } -} -class MenuSearchView(context: Context) : SearchView(context) { - override fun onActionViewCollapsed() { - super.onActionViewCollapsed() + class MenuSearchView(context: Context) : SearchView(context) { + override fun onActionViewCollapsed() { + super.onActionViewCollapsed() + } } } \ No newline at end of file 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 1a946b5e..38387553 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -151,10 +151,8 @@ object BackupUtils { } @Suppress("UNCHECKED_CAST") - private fun getBackup(context: Context?): BackupFile? { - if (context == null) return null - - val syncDataPrefs = getSyncPrefs().all.filter { it.key.isTransferable() } + fun getBackup(context: Context): BackupFile { + val syncDataPrefs = context.getSyncPrefs().all.filter { it.key.isTransferable() } val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() } val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() } @@ -216,9 +214,15 @@ object BackupUtils { // we must remove keys that are not present if (!restoreKeys.isNullOrEmpty()) { - Log.d(BackupAPI.LOG_KEY, "successfulRestore for src=[${restoreSource.name}]: ${restoreData.successfulRestore}") + Log.d( + BackupAPI.LOG_KEY, + "successfulRestore for src=[${restoreSource.name}]: ${restoreData.successfulRestore}" + ) val removedKeys = restoreData.wantToRestore - restoreData.successfulRestore - Log.d(BackupAPI.LOG_KEY, "removed keys for src=[${restoreSource.name}]: $removedKeys") + Log.d( + BackupAPI.LOG_KEY, + "removed keys for src=[${restoreSource.name}]: $removedKeys" + ) removedKeys.forEach { removeKeyRaw(it, restoreSource) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt index 319f09cc..e004b11f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt @@ -7,9 +7,6 @@ import android.util.Log import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.BackupAPI import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.logHistoryChanged -import com.lagradost.cloudstream3.ui.home.HOME_BOOKMARK_VALUE_LIST -import com.lagradost.cloudstream3.ui.player.PLAYBACK_SPEED_KEY -import com.lagradost.cloudstream3.ui.player.RESIZE_MODE_KEY import com.lagradost.cloudstream3.utils.BackupUtils.nonTransferableKeys import com.lagradost.cloudstream3.utils.Coroutines.ioSafe @@ -27,9 +24,6 @@ class Scheduler( *nonTransferableKeys.toTypedArray(), VideoDownloadManager.KEY_DOWNLOAD_INFO, DOWNLOAD_HEADER_CACHE, - PLAYBACK_SPEED_KEY, - HOME_BOOKMARK_VALUE_LIST, - RESIZE_MODE_KEY, ) private val invalidUploadTriggerKeysRegex = listOf( // These trigger automatically every time a show is opened, way too often. @@ -100,6 +94,7 @@ class Scheduler( var lastValue = all registerOnSharedPreferenceChangeListener { sharedPreferences, storeKey -> + if (storeKey == null) return@registerOnSharedPreferenceChangeListener ioSafe { scheduler.work( BackupAPI.PreferencesSchedulerData( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ff03d1fb..d6b02463 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -696,8 +696,6 @@ Qualities Profile background UI was unable to be created correctly, this is a MAJOR BUG and should be reported immediately %s - tv_no_focus_tag - You have already voted Sync file name (optional) Oauth redirect url (optional) https://recloudstream.github.io/cloudstream-sync/google-drive @@ -732,8 +730,6 @@ tv_no_focus_tag - - Enter PIN Enter Current PIN Lock Profile From 7486d89614c84295b8f9d1993098861a750ba3eb Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Tue, 31 Oct 2023 22:38:34 +0100 Subject: [PATCH 32/37] Small fixes --- app/src/main/AndroidManifest.xml | 11 ----------- .../cloudstream3/ui/library/LibraryFragment.kt | 2 -- 2 files changed, 13 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5e50dbc4..453c1fae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -157,17 +157,6 @@ android:pathPrefix="/" android:scheme="https" /> - - - - - - - - - - - Date: Fri, 17 Nov 2023 18:31:20 +0000 Subject: [PATCH 33/37] Update app/src/main/res/layout/add_account_input_oauth.xml Co-authored-by: Luna712 <142361265+Luna712@users.noreply.github.com> --- app/src/main/res/layout/add_account_input_oauth.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/layout/add_account_input_oauth.xml b/app/src/main/res/layout/add_account_input_oauth.xml index acfd59e8..d3701a6d 100644 --- a/app/src/main/res/layout/add_account_input_oauth.xml +++ b/app/src/main/res/layout/add_account_input_oauth.xml @@ -148,6 +148,6 @@ style="@style/BlackButton" android:layout_width="wrap_content" android:layout_gravity="center_vertical|end" - android:text="@string/sort_cancel" /> + android:text="@string/cancel" /> \ No newline at end of file From bbcb70ca989bee7bc36b25f55203f23bffb8dd1b Mon Sep 17 00:00:00 2001 From: CranberrySoup <142951702+CranberrySoup@users.noreply.github.com> Date: Fri, 17 Nov 2023 19:31:55 +0100 Subject: [PATCH 34/37] Moved drawable --- .../{debug => main}/res/drawable/ic_baseline_add_to_drive_24.xml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/{debug => main}/res/drawable/ic_baseline_add_to_drive_24.xml (100%) diff --git a/app/src/debug/res/drawable/ic_baseline_add_to_drive_24.xml b/app/src/main/res/drawable/ic_baseline_add_to_drive_24.xml similarity index 100% rename from app/src/debug/res/drawable/ic_baseline_add_to_drive_24.xml rename to app/src/main/res/drawable/ic_baseline_add_to_drive_24.xml From f33287596d8ff5f393d1b03e1c4af0d78c78ce5e Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Wed, 13 Mar 2024 18:43:38 +0100 Subject: [PATCH 35/37] Update LibraryFragment.kt merge fix --- .../com/lagradost/cloudstream3/ui/library/LibraryFragment.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt index 8d3dde62..686156b4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt @@ -34,7 +34,6 @@ import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser import com.lagradost.cloudstream3.AcraApplication.Companion.setKey -import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R @@ -610,4 +609,4 @@ class LibraryFragment : Fragment() { super.onActionViewCollapsed() } } -} \ No newline at end of file +} From d11dd16ab23d714f951ce9894ae820d53c45e87a Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Wed, 13 Mar 2024 18:45:47 +0100 Subject: [PATCH 36/37] Update InAppUpdater.kt merge fix 2 --- .../main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index 177e3c95..f8f3e24f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri -import android.text.TextUtils import android.util.Log import android.widget.Toast import androidx.appcompat.app.AlertDialog @@ -28,9 +27,7 @@ import okio.sink import java.io.File import android.text.TextUtils import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus import java.io.BufferedReader -import java.io.File import java.io.IOException import java.io.InputStreamReader From 3b036652b926d97b483d539e22ae419818c4abf2 Mon Sep 17 00:00:00 2001 From: firelight <147925818+fire-light42@users.noreply.github.com> Date: Wed, 13 Mar 2024 18:51:09 +0100 Subject: [PATCH 37/37] Update settings_account.xml merge fix 3 --- app/src/main/res/xml/settings_account.xml | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app/src/main/res/xml/settings_account.xml b/app/src/main/res/xml/settings_account.xml index 4ec9dfa3..b0821494 100644 --- a/app/src/main/res/xml/settings_account.xml +++ b/app/src/main/res/xml/settings_account.xml @@ -1,32 +1,32 @@ - + android:icon="@drawable/mal_logo" + android:key="@string/mal_key" /> + + android:icon="@drawable/baseline_cloud_24" + android:key="@string/pcloud_key" /> + android:icon="@drawable/ic_baseline_add_to_drive_24" + android:key="@string/gdrive_key" /> - \ No newline at end of file +