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 61a0634f..79df7221 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,8 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.pm20.util.archivesName import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.ByteArrayOutputStream import java.net.URL +import java.util.Properties +import java.io.FileInputStream plugins { id("com.android.application") @@ -26,6 +28,15 @@ fun String.execute() = ByteArrayOutputStream().use { baot -> else null } +val localProperties = Properties() +try { + localProperties.load(FileInputStream(rootProject.file("local.properties"))) +} catch (_: Exception) { + localProperties.setProperty("debug.gdrive.clientId", "") + localProperties.setProperty("debug.gdrive.secret", "") +} + + android { testOptions { unitTests.isReturnDefaultValues = true @@ -112,6 +123,16 @@ 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") ?: "" + ) } } @@ -229,6 +250,26 @@ dependencies { ^ Don't Bump Jackson above 2.13.1 , Crashes on Android TV's and FireSticks that have Min API Level 25 or Less. */ + // color palette for images -> colors + implementation("androidx.palette:palette-ktx:1.0.0") + + implementation("org.skyscreamer:jsonassert:1.2.3") + 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", + ) + } + // Downloading & Networking implementation("androidx.work:work-runtime:2.9.0") implementation("androidx.work:work-runtime-ktx:2.9.0") diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index cc2c99de..8ff710fe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -87,6 +87,7 @@ import com.lagradost.cloudstream3.plugins.PluginManager.loadAllOnlinePlugins import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver import com.lagradost.cloudstream3.services.SubscriptionWorkManager +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 @@ -314,6 +315,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() @@ -697,6 +699,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java) this.sendBroadcast(broadcastIntent) afterPluginsLoadedEvent -= ::onAllPluginsLoaded + // run sync before app quits + BackupApis.forEach { it.scheduleUpload() } super.onDestroy() } 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 a14f8438..c1fbee69 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.syncproviders +import androidx.annotation.WorkerThread import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys import com.lagradost.cloudstream3.AcraApplication.Companion.setKey @@ -14,18 +15,26 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { val simklApi = SimklApi(0) val addic7ed = Addic7ed() val subDlApi = SubDlApi(0) + val googleDriveApi = GoogleDriveApi(0) + val pcloudApi = PcloudApi(0) val localListApi = LocalList() // used to login via app intent val OAuth2Apis get() = listOf( - malApi, aniListApi, simklApi + malApi, aniListApi, simklApi, googleDriveApi, pcloudApi ) // this needs init with context and can be accessed in settings val accountManagers get() = listOf( - malApi, aniListApi, openSubtitlesApi, subDlApi, simklApi //nginxApi + malApi, + aniListApi, + openSubtitlesApi, + subDlApi, + simklApi, + googleDriveApi, + pcloudApi //, nginxApi ) // used for active syncing @@ -34,10 +43,18 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi) ) + // used for active backup + val BackupApis + get() = listOf( + googleDriveApi, pcloudApi + ) + val inAppAuths - get() = listOf( + get() = listOf( openSubtitlesApi, - subDlApi + subDlApi, + googleDriveApi, + pcloudApi )//, nginxApi) val subtitleProviders @@ -87,12 +104,18 @@ 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 private val accountsKey get() = "${idPrefix}_accounts" + + // runs on startup + @WorkerThread + open suspend fun initialize() { + } + protected fun removeAccountKeys() { removeKeys(accountId) val accounts = getAccounts()?.toMutableList() ?: mutableListOf() @@ -119,6 +142,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 new file mode 100644 index 00000000..1891db2e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -0,0 +1,411 @@ +package com.lagradost.cloudstream3.syncproviders + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +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.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 +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.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.minutes + +interface RemoteFile { + class Error(val message: String? = null, val throwable: Throwable? = null) : RemoteFile + class NotFound : 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), 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" + + // 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 = 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(getBackup(context), 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() + } + + /** + * 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) + } + } + + private var willUploadSoon: Boolean? = null + private var uploadJob: Job? = null + + private fun shouldUploadBackup(): Boolean { + val ctx = AcraApplication.context ?: return false + + val newBackup = getBackup(ctx).toJson() + return compareJson(lastBackupJson ?: "", newBackup).failed + } + + override fun scheduleUpload() { + normalSafeApiCall { + if (!shouldUploadBackup()) { + willUploadSoon = false + Log.d(LOG_KEY, "${this.name}: upload not required, data is same") + return@normalSafeApiCall + } + + upload() + } + } + + // changedKey and isSettings is currently unused, might be useful for more efficient update checker. + override fun scheduleUpload(changedKey: String, isSettings: Boolean) { + normalSafeApiCall { + 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 = getBackup(context).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) + } + + + // ------ 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/InAppAuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt index 8b6fdf46..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 { @@ -35,11 +33,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() } @@ -52,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 new file mode 100644 index 00000000..9eccfc52 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt @@ -0,0 +1,33 @@ +package com.lagradost.cloudstream3.syncproviders + +import androidx.fragment.app.FragmentActivity +import com.fasterxml.jackson.annotation.JsonIgnore + +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 + val infoUrl: 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? +} \ 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..1f8f983e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -0,0 +1,429 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.fragment.app.FragmentActivity +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 +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 +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.BackupAPI +import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API +import com.lagradost.cloudstream3.syncproviders.RemoteFile +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe +import kotlinx.coroutines.runBlocking +import java.io.InputStream +import java.util.Date + + +/** + * ## Improvements and ideas + * + * | State | Priority | Description + * |---------:|:--------:|--------------------------------------------------------------------- + * | 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 + * | | | 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 + * | 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) : + BackupAPI(index), InAppOAuth2API { + 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 requiresLogin = true + override val createAccountUrl = null + override val requiresFilename = true + 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 infoUrl = "https://recloudstream.github.io/cloudstream-sync/google-drive/help.html" + + private var tempAuthFlow: AuthorizationCodeFlow? = 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 + 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 + ) + + registerAccount() + storeValue(K.TOKEN, googleTokenResponse) + storeValue(K.IS_READY, true) + + // First launch overwrites + scheduleDownload(runNow = true, overwrite = true) + + tempAuthFlow = null + return true + } + + override suspend fun initialize() { + ioSafe { + scheduleDownload(true) + } + } + + 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? { + val driveService = getLatestLoginData()?.let { getDriveService(it) } ?: return null + val userInfo = runBlocking { + getUserInfo(driveService) + } ?: getBlankUser() + + return AuthAPI.LoginInfo( + name = userInfo.displayName, + profilePicture = userInfo.photoLink, + accountIndex = accountIndex + ) + } + + ///////////////////////////////////////// + ///////////////////////////////////////// + // InAppOAuth2API implementation + override suspend fun getAuthorizationToken( + activity: FragmentActivity, + data: InAppOAuth2API.LoginData + ) { + val credential = loginInfo() + // Repeated attempts will not switch account because IS_READY is false + if (credential != null && getValue(K.IS_READY) != false) { + switchToNewAccount() + } + + storeValue(K.IS_READY, false) + storeValue(K.LOGIN_DATA, data) + + val authFlow = GAPI.createAuthFlow(data.clientId, data.secret) + this.tempAuthFlow = authFlow + + try { + val url = authFlow.newAuthorizationUrl().setRedirectUri(data.redirectUrl).build() + openBrowser(url) + } catch (e: Throwable) { + CommonActivity.showToast( + activity, + activity.getString(R.string.authenticated_user_fail).format(name) + ) + } + } + + override fun getLatestLoginData(): InAppOAuth2API.LoginData? { + return getValue(K.LOGIN_DATA) + } + + override suspend fun getLoginData(): InAppOAuth2API.LoginData? { + return getLatestLoginData() + } + + ///////////////////////////////////////// + ///////////////////////////////////////// + // BackupAPI implementation + override suspend fun isReady(): Boolean { + val loginData = getLatestLoginData() + return getValue(K.IS_READY) == true && + loginInfo() != null && + loginData != null && + getDriveService(loginData) != null && + AcraApplication.context != null + } + + 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) + ioFile.writeText(backupJson) + + val fileMetadata = File() + fileMetadata.name = fileName + fileMetadata.mimeType = "application/json" + val fileContent = FileContent("application/json", ioFile) + + val fileId = getOrFindExistingSyncFileId(drive, loginData) + if (fileId != null) { + try { + 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() + loginData.syncFileId = file.id + } + } else { + val file = drive.files().create(fileMetadata, fileContent).execute() + loginData.syncFileId = file.id + } + + // in case we had to create new file + if (syncFileId != loginData.syncFileId) { + storeValue(K.LOGIN_DATA, loginData) + } + } + + private fun getOrFindExistingSyncFileId( + drive: Drive, + loginData: InAppOAuth2API.LoginData + ): String? { + if (loginData.syncFileId != null) { + try { + val verified = drive.files().get(loginData.syncFileId).execute() + return verified.id + } catch (_: Exception) { + } + } + + val existingFileId: String? = drive + .files() + .list() + .setQ("name='${loginData.fileName}' and trashed=false") + .execute() + .files + ?.getOrNull(0) + ?.id + + if (existingFileId != null) { + loginData.syncFileId = existingFileId + storeValue(K.LOGIN_DATA, loginData) + + return existingFileId + } + + return null + } + + private fun getDriveService(loginData: InAppOAuth2API.LoginData): Drive? { + val credential = getCredentialsFromStore(loginData) ?: return null + + return Drive.Builder( + GAPI.HTTP_TRANSPORT, + GAPI.JSON_FACTORY, + credential + ) + .setApplicationName("cloudstreamapp-drive-sync") + .build() + } + + + private fun getCredentialsFromStore(loginData: InAppOAuth2API.LoginData): Credential? { + val token = getValue(K.TOKEN) + + val credential = if (token != null) { + GAPI.getCredentials(token, loginData) + } else { + return null + } + + if (credential.expirationTimeMilliseconds < Date().time) { + val success = credential.refreshToken() + + if (!success) { + logOut() + return null + } + } + + return credential + } + + override fun logOut() { + K.values().forEach { clearValue(it) } + removeAccountKeys() + } + + ///////////////////////////////////////// + ///////////////////////////////////////// + // 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: NetHttpTransport = GoogleNetHttpTransport.newTrustedTransport() + val JSON_FACTORY: GsonFactory = 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/syncproviders/providers/OpenSubtitlesApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt index 7d0514d1..61ae5965 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/library/LibraryFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt index 90e57ef4..6c9978bc 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 @@ -7,6 +7,8 @@ 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.util.TypedValue import android.view.LayoutInflater import android.view.View @@ -22,7 +24,6 @@ import androidx.appcompat.widget.SearchView import androidx.core.view.allViews import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView @@ -42,6 +43,7 @@ import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.logError 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.AutofitRecyclerView @@ -60,6 +62,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount +import org.checkerframework.framework.qual.Unused import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.abs @@ -583,6 +586,21 @@ class LibraryFragment : Fragment() { 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) + } + private val sortChangeClickListener = View.OnClickListener { view -> val methods = libraryViewModel.sortingMethods.map { txt(it.stringRes).asString(view.context) @@ -598,10 +616,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/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index c77f9404..b46b6aba 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 @@ -37,6 +37,7 @@ import com.lagradost.cloudstream3.mvvm.* import com.lagradost.cloudstream3.subtitles.AbstractSubApi import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders +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 @@ -51,6 +52,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 @@ -772,6 +774,7 @@ class GeneratorPlayer : FullScreenPlayer() { binding.subtitlesClickSettings.setOnClickListener { 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/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 3ec47648..ba0b806f 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,12 +2,9 @@ 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 @@ -20,16 +17,19 @@ import com.lagradost.cloudstream3.CommonActivity.showToast 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.mvvm.normalSafeApiCall 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 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.AccountManager.Companion.subDlApi 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.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE @@ -40,6 +40,8 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar +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.AppUtils.html import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BiometricAuthenticator @@ -130,129 +132,12 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricAuthenticator.Biome } @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 (isLayout(TV or EMULATOR)) { - 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") } @@ -324,6 +209,8 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricAuthenticator.Biome R.string.simkl_key to simklApi, R.string.opensubtitles_key to openSubtitlesApi, R.string.subdl_key to subDlApi, + R.string.gdrive_key to googleDriveApi, + R.string.pcloud_key to pcloudApi, ) for ((key, api) in syncApis) { @@ -331,11 +218,11 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricAuthenticator.Biome 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 { - addAccount(activity, api) + activity?.let { activity -> addAccount(activity, api) } } return@setOnPreferenceClickListener true } 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 6ba93c0f..b4c0a195 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 @@ -6,6 +6,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.updateLayoutParams @@ -15,10 +16,12 @@ import androidx.preference.PreferenceFragmentCompat import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.MaterialToolbar import com.lagradost.cloudstream3.BuildConfig +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 +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.BackupApis import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers import com.lagradost.cloudstream3.ui.home.HomeFragment import com.lagradost.cloudstream3.ui.result.txt @@ -27,6 +30,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper @@ -106,7 +110,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) @@ -122,7 +127,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) @@ -231,6 +237,22 @@ 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.getIsLoggedIn() + } + + 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 (isLayout(TV)) { settingsGeneral.requestFocus() 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 22a7e098..8eb95e7c 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 @@ -26,6 +26,7 @@ import com.lagradost.cloudstream3.databinding.AddSiteInputBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.network.initClient +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.EasterEggMonke import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE @@ -39,6 +40,7 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setTool import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.isAppRestricted import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.showBatteryOptimizationDialog +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 @@ -166,13 +168,19 @@ 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() + .attachBackupListener(context.getSyncPrefs()).self + .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 (filePath ?: uri.toString()).let { PreferenceManager.getDefaultSharedPreferences(context) - .edit().putString(getString(R.string.download_path_pref), it).apply() + .attachBackupListener(context.getSyncPrefs()).self + .edit() + .putString(getString(R.string.download_path_pref), it) + .apply() } } @@ -180,6 +188,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { hideKeyboard() setPreferencesFromResource(R.xml.settings_general, rootKey) 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 20279cd1..a8466c03 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.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -20,6 +21,7 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setTool 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 @@ -36,6 +38,7 @@ class SettingsPlayer : PreferenceFragmentCompat() { hideKeyboard() setPreferencesFromResource(R.xml.settings_player, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + .attachBackupListener(requireContext().getSyncPrefs()).self //Hide specific prefs on TV/EMULATOR hidePrefs( 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 7dc73a46..02b5d93e 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,26 @@ 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.AllLanguagesName +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +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 import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.utils.DataStoreHelper +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?) { @@ -33,6 +39,7 @@ class SettingsProviders : PreferenceFragmentCompat() { hideKeyboard() setPreferencesFromResource(R.xml.settings_providers, rootKey) 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 cc14e761..744544dc 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,14 @@ import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.settings.Globals.updateTv 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.setToolBarScrollFlags 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 @@ -31,6 +33,7 @@ class SettingsUI : PreferenceFragmentCompat() { hideKeyboard() setPreferencesFromResource(R.xml.settings_ui, rootKey) 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 4aaa5e12..2c609624 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 @@ -15,6 +15,7 @@ import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.databinding.LogcatBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.network.initClient +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener import com.lagradost.cloudstream3.services.BackupWorkManager import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref @@ -24,6 +25,7 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpTo import com.lagradost.cloudstream3.utils.BackupUtils 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.SingleSelectionHelper.showDialog @@ -157,6 +159,7 @@ class SettingsUpdates : PreferenceFragmentCompat() { getPref(R.string.apk_installer_key)?.setOnPreferenceClickListener { 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/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..0686cdc3 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt @@ -0,0 +1,137 @@ +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 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( + private val api: AuthAPI, + private val activity: FragmentActivity?, + private val themeResId: Int, + val binding: T, +) { + class CommonDialogItems( + private val dialog: AlertDialog, + private val title: TextView, + private val btnApply: MaterialButton, + private val btnCancel: MaterialButton, + private val btnAccCreate: 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 getBtnInfo() = dialog.getCommonItem(btnInfo) + + 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(binding.root) + 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 + } + + 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 + } + + } +} \ 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..1917546b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt @@ -0,0 +1,103 @@ +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.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 + +class InAppAuthDialogBuilder( + private val api: InAppAuthAPI, + private val activity: FragmentActivity, +) : DialogBuilder( + api, + activity, + R.style.AlertDialogCustom, + AddAccountInputBinding.inflate(activity.layoutInflater), +) { + + override fun onLogin(dialog: AlertDialog): Unit = with(binding) { +// if (activity == null) throw IllegalStateException("Login should be called after validation") + + 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, + server = if (api.requiresServer) loginServerInput.text?.toString() else null, + ) + + ioSafe { + val isSuccessful = try { + api.login(userData) + } catch (e: Exception) { + logError(e) + false + } + if (isSuccessful) { + dialog.dismissSafe() + } + 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(binding) { + CommonDialogItems(dialog, text1, applyBtt, cancelBtt, createAccount,null) + } + + override fun getVisibilityMap(dialog: AlertDialog): Map = with(binding) { + mapOf( + loginEmailInput to api.requiresEmail, + loginPasswordInput to api.requiresPassword, + loginServerInput to api.requiresServer, + loginUsernameInput to 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 + + createAccount.isGone = api.createAccountUrl.isNullOrBlank() + createAccount.setOnClickListener { + AcraApplication.openBrowser( + api.createAccountUrl ?: return@setOnClickListener, activity + ) + + dialog.dismissSafe() + } + } + + override fun handleStoresPasswordInPlainText(dialog: AlertDialog): Unit = with(binding) { + if (!api.storesPasswordInPlainText) return + + api.getUserData()?.let { data -> + 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 new file mode 100644 index 00000000..85ae3db1 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt @@ -0,0 +1,80 @@ +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.Companion.openBrowser +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 + + +class InAppOAuth2DialogBuilder( + private val api: InAppOAuth2API, + 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(binding) { + mapOf( + loginFileName to api.requiresFilename, + loginClientId to api.requiresClientId, + loginClientSecret to api.requiresSecret, + ) + } + + override fun setupItems(dialog: AlertDialog): Unit = with(binding) { + loginFileName.isVisible = api.requiresFilename + loginClientId.isVisible = api.requiresClientId + loginClientSecret.isVisible = api.requiresSecret + + infoButton.isGone = api.infoUrl.isNullOrBlank() + infoButton.setOnClickListener { + api.infoUrl?.let { url -> openBrowser(url) } + } + } + + + override fun onLogin(dialog: AlertDialog): Unit = with(binding) { +// if (this == null) throw IllegalStateException("Login should be called after validation") + + val ctx = this.root.context + + val clientId = loginClientId.text.toString().ifBlank { + ctx.getString(R.string.debug_gdrive_clientId) + } + val clientSecret = loginClientSecret.text.toString().ifBlank { + ctx.getString(R.string.debug_gdrive_secret) + } + val syncFileName = loginFileName.text.toString().trim().ifBlank { + api.defaultFilenameValue + } + val redirectUrl = loginFileName.text.toString().trim().ifBlank { + api.defaultRedirectUrl + } + + ioSafe { + api.getAuthorizationToken( + this@InAppOAuth2DialogBuilder.activity, + InAppOAuth2API.LoginData( + clientId = clientId, + secret = clientSecret, + fileNameInput = syncFileName, + redirectUrl = redirectUrl, + syncFileId = null + ) + ) + } + + dialog.dismissSafe() + } +} 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 5c473b73..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 @@ -16,8 +16,10 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentSetupLanguageBinding import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.plugins.PluginManager +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 import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar @@ -51,6 +53,7 @@ class SetupFragmentLanguage : Fragment() { val ctx = context ?: return@normalSafeApiCall val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + .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 d8fa46e6..6f2e3c66 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 @@ -14,6 +14,8 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentSetupLayoutBinding import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import org.acra.ACRA @@ -44,6 +46,7 @@ class SetupFragmentLayout : Fragment() { val ctx = context ?: return@normalSafeApiCall val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + .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 f9197213..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,13 +10,14 @@ 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 import com.lagradost.cloudstream3.mvvm.normalSafeApiCall 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 class SetupFragmentMedia : Fragment() { @@ -45,6 +46,7 @@ class SetupFragmentMedia : Fragment() { val ctx = context ?: return@normalSafeApiCall val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + .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 59dcc402..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 @@ -18,6 +18,8 @@ import com.lagradost.cloudstream3.databinding.FragmentSetupProviderLanguagesBind import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs class SetupFragmentProviderLanguage : Fragment() { var binding: FragmentSetupProviderLanguagesBinding? = null @@ -46,6 +48,7 @@ class SetupFragmentProviderLanguage : Fragment() { val ctx = context ?: return@normalSafeApiCall val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + .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/subtitles/SubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt index 1466afed..5aeb849d 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 @@ -31,6 +31,8 @@ import com.lagradost.cloudstream3.databinding.SubtitleSettingsBinding import com.lagradost.cloudstream3.ui.settings.Globals import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener +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 @@ -458,6 +460,7 @@ class SubtitlesFragment : Fragment() { subtitlesFilterSubLang.setOnCheckedChangeListener { _, b -> context?.let { ctx -> PreferenceManager.getDefaultSharedPreferences(ctx) + .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 1d23e503..01b16f5f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.utils import android.annotation.SuppressLint import android.content.Context import android.net.Uri +import android.util.Log import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts @@ -12,14 +13,18 @@ import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.module.kotlin.readValue import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity 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.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 @@ -32,7 +37,10 @@ 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 import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream @@ -44,11 +52,17 @@ import java.text.SimpleDateFormat import java.util.Date object BackupUtils { + enum class RestoreSource { + 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, @@ -59,6 +73,8 @@ object BackupUtils { MAL_CACHED_LIST, MAL_UNIXTIME_KEY, MAL_USER_KEY, + GoogleDriveApi.K.TOKEN.value, + GoogleDriveApi.K.IS_READY.value, // The plugins themselves are not backed up PLUGINS_KEY, @@ -82,6 +98,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?, @@ -89,20 +115,62 @@ 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("settings") val settings: BackupVars, + @JsonProperty("sync-meta") val syncMeta: BackupVars = 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 + } + + fun getData(source: RestoreSource) = when (source) { + RestoreSource.SYNC -> syncMeta + RestoreSource.DATA -> datastore + RestoreSource.SETTINGS -> settings + } + } @Suppress("UNCHECKED_CAST") - private fun getBackup(context: Context?): BackupFile? { - if (context == null) return null - + 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() } + 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, @@ -123,35 +191,51 @@ object BackupUtils { return BackupFile( allDataSorted, - allSettingsSorted + allSettingsSorted, + syncData ) } @WorkerThread - fun restore( - context: Context?, + fun Context.restore(backupFile: BackupFile, restoreKeys: Set? = null) = restore( + backupFile, + restoreKeys, + RestoreSource.SYNC, + RestoreSource.DATA, + RestoreSource.SETTINGS + ) + + @WorkerThread + fun Context.restore( backupFile: BackupFile, - restoreSettings: Boolean, - restoreDataStore: Boolean + restoreKeys: Set? = null, + vararg restoreSources: RestoreSource ) { - if (context == null) return - if (restoreSettings) { - context.restoreMap(backupFile.settings._Bool, true) - context.restoreMap(backupFile.settings._Int, true) - context.restoreMap(backupFile.settings._String, true) - context.restoreMap(backupFile.settings._Float, true) - context.restoreMap(backupFile.settings._Long, true) - context.restoreMap(backupFile.settings._StringSet, true) + Log.d(BackupAPI.LOG_KEY, "will restore keys = $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 (restoreDataStore) { - context.restoreMap(backupFile.datastore._Bool) - context.restoreMap(backupFile.datastore._Int) - context.restoreMap(backupFile.datastore._String) - context.restoreMap(backupFile.datastore._Float) - context.restoreMap(backupFile.datastore._Long) - context.restoreMap(backupFile.datastore._StringSet) - } + Log.d(BackupAPI.LOG_KEY, "restore on ui event fired") + afterBackupRestoreEvent.invoke(Unit) } @SuppressLint("SimpleDateFormat") @@ -208,15 +292,7 @@ object BackupUtils { val input = activity.contentResolver.openInputStream(uri) ?: return@ioSafe - val restoredValue = - mapper.readValue(input) - - restore( - activity, - restoredValue, - restoreSettings = true, - restoreDataStore = true - ) + activity.restore(mapper.readValue(input)) activity.runOnUiThread { activity.recreate() } } catch (e: Exception) { logError(e) @@ -256,14 +332,54 @@ object BackupUtils { private fun Context.restoreMap( map: Map?, - isEditingAppSettings: Boolean = false - ) { - val editor = DataStore.editor(this, isEditingAppSettings) - map?.forEach { - if (it.key.isTransferable()) { - editor.setKeyRaw(it.key, it.value) + restoreSource: RestoreSource, + restoreKeys: Set? = null + ): RestoreMapData { + val restoreOnlyThese = mutableSetOf() + val successfulRestore = mutableSetOf() + + if (!restoreKeys.isNullOrEmpty()) { + 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(prefixToRemove) + } + + restoreOnlyThese.addAll(restore) } - editor.apply() + + + map?.filter { + var isTransferable = it.key.withoutPrefix(restoreSource).isTransferable() + + if (isTransferable && restoreOnlyThese.isNotEmpty()) { + isTransferable = restoreOnlyThese.contains(it.key.withoutPrefix(restoreSource)) + } + + if (isTransferable) { + successfulRestore.add(it.key.withoutPrefix(restoreSource)) + } + + isTransferable + }?.forEach { + setKeyRaw(it.key.withoutPrefix(restoreSource), it.value, restoreSource) + } + + return RestoreMapData( + restoreOnlyThese, + 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 19c817b9..dfa7db6f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -10,8 +10,10 @@ 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.utils.Coroutines.ioSafe const val DOWNLOAD_HEADER_CACHE = "download_header_cache" @@ -22,6 +24,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" // TODO degelgate by value for get & set @@ -29,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 @@ -50,29 +54,8 @@ class PreferenceDelegate( } } -/** When inserting many keys use this function, this is because apply for every key is very expensive on memory */ -data class Editor( - val editor : SharedPreferences.Editor -) { - /** Always remember to call apply after */ - fun setKeyRaw(path: String, value: T) { - when (value) { - is Boolean -> editor.putBoolean(path, value) - is Int -> editor.putInt(path, value) - is String -> editor.putString(path, value) - is Float -> editor.putFloat(path, value) - is Long -> editor.putLong(path, value) - (value as? Set != null) -> editor.putStringSet(path, value as Set) - } - } - - fun apply() { - editor.apply() - System.gc() - } -} - object DataStore { + private val backupScheduler = Scheduler.createBackupScheduler() val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() @@ -80,6 +63,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) } @@ -88,10 +79,38 @@ object DataStore { return "${folder}/${path}" } - fun editor(context : Context, isEditingAppSettings: Boolean = false) : Editor { - val editor: SharedPreferences.Editor = - if (isEditingAppSettings) context.getDefaultSharedPrefs().edit() else context.getSharedPrefs().edit() - return Editor(editor) + fun Context.setKeyRaw(path: String, value: T, restoreSource: BackupUtils.RestoreSource) { + try { + 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) + is String -> editor.putString(path, value) + is Float -> editor.putFloat(path, value) + is Long -> editor.putLong(path, value) + (value as? Set != null) -> editor.putStringSet(path, value as Set) + } + editor.apply() + } catch (e: Exception) { + 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 { @@ -119,9 +138,23 @@ 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() + + ioSafe { + backupScheduler.work( + BackupAPI.PreferencesSchedulerData( + getSyncPrefs(), + path, + oldValueExists, + false, + BackupUtils.RestoreSource.DATA + ) + ) + } } } catch (e: Exception) { logError(e) @@ -138,9 +171,25 @@ object DataStore { fun Context.setKey(path: String, value: T) { try { - val editor: SharedPreferences.Editor = getSharedPrefs().edit() - editor.putString(path, mapper.writeValueAsString(value)) + val prefs = getSharedPrefs() + val oldValue = prefs.getString(path, null) + val newValue = mapper.writeValueAsString(value) + + val editor: SharedPreferences.Editor = prefs.edit() + editor.putString(path, newValue) editor.apply() + + ioSafe { + backupScheduler.work( + BackupAPI.PreferencesSchedulerData( + getSyncPrefs(), + path, + oldValue, + newValue, + BackupUtils.RestoreSource.DATA + ) + ) + } } catch (e: Exception) { logError(e) } @@ -159,6 +208,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 d9a31b4e..f8f3e24f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -14,8 +14,11 @@ 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.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 +import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.BufferedSink @@ -24,7 +27,6 @@ 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.IOException import java.io.InputStreamReader @@ -75,6 +77,7 @@ class InAppUpdater { private suspend fun Activity.getAppUpdate(): Update { return try { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + .attachBackupListener(getSyncPrefs()).self if (settingsManager.getBoolean( getString(R.string.prerelease_update_key), resources.getBoolean(R.bool.is_prerelease) @@ -256,7 +259,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) + val settingsManager = + PreferenceManager.getDefaultSharedPreferences(this) + .attachBackupListener(getSyncPrefs()).self if (!checkAutoUpdate || settingsManager.getBoolean( getString(R.string.auto_update_key), @@ -266,7 +271,8 @@ class InAppUpdater { val update = getAppUpdate() if ( update.shouldUpdate && - update.updateURL != null) { + update.updateURL != null + ) { // Check if update should be skipped val updateNodeId = 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..e004b11f --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt @@ -0,0 +1,175 @@ +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.utils.BackupUtils.nonTransferableKeys +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe + +class Scheduler( + private val throttleTimeMs: Long, + 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 + + // 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, + ) + 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>( + BackupAPI.UPLOAD_THROTTLE.inWholeMilliseconds, + onWork = { input -> + AccountManager.BackupApis.forEach { api -> + api.scheduleUpload( + input.storeKey, + input.source == BackupUtils.RestoreSource.SETTINGS + ) + } + }, + beforeWork = { _ -> + AccountManager.BackupApis.filter { api -> + api.getIsReady() + }.forEach { + it.setIsUploadingSoon() + } + }, + canWork = { input -> + val hasSomeActiveManagers = AccountManager.BackupApis.any { it.getIsReady() } + if (!hasSomeActiveManagers) { + 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 + } + ) + + // 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( + source: BackupUtils.RestoreSource = BackupUtils.RestoreSource.SETTINGS, + syncPrefs: SharedPreferences + ): BackupAPI.SharedPreferencesWithListener { + val scheduler = createBackupScheduler() + + var lastValue = all + registerOnSharedPreferenceChangeListener { sharedPreferences, storeKey -> + if (storeKey == null) return@registerOnSharedPreferenceChangeListener + ioSafe { + 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(BackupUtils.RestoreSource.SETTINGS, syncPrefs) + } + } + + private val id = SCHEDULER_ID++ + private val handler = Handler(Looper.getMainLooper()) + private var runnable: Runnable? = null + + suspend fun work(input: INPUT): 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}]") + beforeWork?.invoke(input) + throttle(input) + + return true + } + + suspend fun workNow(input: INPUT): 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}]") + beforeWork?.invoke(input) + stop() + onWork(input) + + return true + } + + fun stop() { + runnable?.let { + handler.removeCallbacks(it) + runnable = null + } + } + + /** + * 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") + ioSafe { + onWork(input) + } + }.also { run -> + handler.postDelayed(run, 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/drawable/ic_baseline_add_to_drive_24.xml b/app/src/main/res/drawable/ic_baseline_add_to_drive_24.xml new file mode 100644 index 00000000..030046d3 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_add_to_drive_24.xml @@ -0,0 +1,17 @@ + + + + + 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..d3701a6d --- /dev/null +++ b/app/src/main/res/layout/add_account_input_oauth.xml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/main_settings.xml b/app/src/main/res/layout/main_settings.xml index 0b931843..07592175 100644 --- a/app/src/main/res/layout/main_settings.xml +++ b/app/src/main/res/layout/main_settings.xml @@ -44,9 +44,10 @@ + + + Poster title location Put the title under the poster + Plugins + Remote Sync anilist_key simkl_key mal_key opensubtitles_key subdl_key + gdrive_key + pcloud_key nginx_key password123 Username @@ -482,6 +486,9 @@ NewSiteName https://example.com Language code (en) + cloudstreamapp-sync-file + OAuth Client ID + OAuth Client Secret