diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 158fabb7..4a9fbd62 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -90,7 +90,6 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStri import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringSearch import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths -import com.lagradost.cloudstream3.syncproviders.providers.localnetwork.LocalNetworkApi import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO @@ -685,7 +684,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { this.sendBroadcast(broadcastIntent) afterPluginsLoadedEvent -= ::onAllPluginsLoaded // run sync before app quits - BackupApis.forEach { it.addToQueueNow() } + BackupApis.forEach { it.scheduleUpload() } super.onDestroy() } @@ -1595,10 +1594,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { // showToast(this, currentFocus.toString(), Toast.LENGTH_LONG) // } // } - - val local = LocalNetworkApi(this) - local.registerService() - local.discover() } suspend fun checkGithubConnectivity(): Boolean { diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt index 54128112..b73105af 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -14,6 +14,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { val openSubtitlesApi = OpenSubtitlesApi(0) val simklApi = SimklApi(0) val googleDriveApi = GoogleDriveApi(0) + val pcloudApi = PcloudApi(0) val indexSubtitlesApi = IndexSubtitleApi() val addic7ed = Addic7ed() val localListApi = LocalList() @@ -21,13 +22,18 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { // used to login via app intent val OAuth2Apis get() = listOf( - malApi, aniListApi, simklApi, googleDriveApi + malApi, aniListApi, simklApi, googleDriveApi, pcloudApi ) // this needs init with context and can be accessed in settings val accountManagers get() = listOf( - malApi, aniListApi, openSubtitlesApi, simklApi, googleDriveApi //, nginxApi + malApi, + aniListApi, + openSubtitlesApi, + simklApi, + googleDriveApi, + pcloudApi //, nginxApi ) // used for active syncing @@ -39,12 +45,12 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { // used for active backup val BackupApis get() = listOf>( - googleDriveApi + googleDriveApi, pcloudApi ) val inAppAuths get() = listOf( - openSubtitlesApi, googleDriveApi//, nginxApi + openSubtitlesApi, googleDriveApi, pcloudApi//, nginxApi ) val subtitleProviders @@ -94,7 +100,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { var accountIndex = defIndex private var lastAccountIndex = defIndex - protected val accountId get() = "${idPrefix}_account_$accountIndex" + val accountId get() = "${idPrefix}_account_$accountIndex" private val accountActiveKey get() = "${idPrefix}_active" // int array of all accounts indexes @@ -132,6 +138,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { lastAccountIndex = accountIndex accountIndex = (accounts?.maxOrNull() ?: 0) + 1 } + protected fun switchToOldAccount() { accountIndex = lastAccountIndex } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt index 66acb5f4..1a292195 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -4,14 +4,19 @@ import android.content.Context import android.content.SharedPreferences import android.util.Log import com.fasterxml.jackson.module.kotlin.readValue -import com.lagradost.cloudstream3.mvvm.launchSafe +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.mvvm.debugException +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.LOG_KEY +import com.lagradost.cloudstream3.syncproviders.IBackupAPI.Companion.compareJson +import com.lagradost.cloudstream3.syncproviders.IBackupAPI.Companion.mergeBackup +import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.getBackup import com.lagradost.cloudstream3.utils.BackupUtils.restore +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStore import com.lagradost.cloudstream3.utils.Scheduler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import org.skyscreamer.jsonassert.JSONCompare import org.skyscreamer.jsonassert.JSONCompareMode @@ -19,7 +24,180 @@ import org.skyscreamer.jsonassert.JSONCompareResult import kotlin.system.measureTimeMillis import kotlin.time.Duration.Companion.seconds -interface BackupAPI { +interface RemoteFile { + class Error(val message: String? = null, val throwable: Throwable? = null) : RemoteFile + class NotFound : RemoteFile + class Success(val remoteData: String) : RemoteFile +} + +abstract class BackupAPI(defIndex: Int) : IBackupAPI, + AccountManager(defIndex) { + companion object { + const val LOG_KEY = "BACKUP" + + // Can be called in high frequency (for now) because current implementation uses google + // cloud project per user so there is no way to hit quota. Later we should implement + // some kind of adaptive throttling which will increase decrease throttle time based + // on factors like: live devices, quota limits, etc + val UPLOAD_THROTTLE = 30.seconds + val DOWNLOAD_THROTTLE = 120.seconds + } + + /** + * Cached last uploaded json file, to prevent unnecessary uploads. + */ + private var lastBackupJson: String? = null + + /** + * Continually tries to download from the service. + */ + private val continuousDownloader = Scheduler( + DOWNLOAD_THROTTLE.inWholeMilliseconds, + onWork = { overwrite -> + if (uploadJob?.isActive == true || willUploadSoon == true) { + uploadJob?.invokeOnCompletion { + Log.d(LOG_KEY, "${this.name}: upload is running, reschedule download") + ioSafe { + scheduleDownload(false, overwrite) + } + } + } else { + Log.d(LOG_KEY, "${this.name}: downloadSyncData will run") + val context = AcraApplication.context ?: return@Scheduler + mergeRemoteBackup(context, overwrite) + } + } + ) + + suspend fun scheduleDownload(runNow: Boolean = false, overwrite: Boolean = false) { + if (runNow) { + continuousDownloader.workNow(overwrite) + } else { + continuousDownloader.work(overwrite) + } + } + + var willUploadSoon: Boolean? = null + private var uploadJob: Job? = null + + private fun shouldUploadBackup(): Boolean { + val ctx = AcraApplication.context ?: return false + + val newBackup = ctx.getBackup().toJson() + return compareJson(lastBackupJson ?: "", newBackup).failed + } + + fun scheduleUpload() { + if (!shouldUploadBackup()) { + willUploadSoon = false + Log.d(LOG_KEY, "${this.name}: upload not required, data is same") + return + } + + upload() + } + + // changedKey and isSettings is currently unused, might be useful for more efficient update checker. + fun scheduleUpload(changedKey: String, isSettings: Boolean) { + scheduleUpload() + } + + private fun upload() { + if (uploadJob != null && uploadJob!!.isActive) { + Log.d(LOG_KEY, "${this.name}: upload is canceled, scheduling new") + uploadJob?.cancel() + } + + val context = AcraApplication.context ?: return + uploadJob = ioSafe { + willUploadSoon = false + Log.d(LOG_KEY, "$name: uploadBackup is launched") + uploadBackup(context) + } + } + + /** + * Uploads the app data to the service if the api is ready and has login data. + * @see isReady + * @see getLoginData + */ + private suspend fun uploadBackup(context: Context) { + val isReady = isReady() + if (!isReady) { + Log.d(LOG_KEY, "${this.name}: uploadBackup is not ready yet") + return + } + + val loginData = getLoginData() + if (loginData == null) { + Log.d(LOG_KEY, "${this.name}: uploadBackup did not get loginData") + return + } + + val backupFile = context.getBackup().toJson() + lastBackupJson = backupFile + Log.d(LOG_KEY, "${this.name}: uploadFile is now running") + uploadFile(context, backupFile, loginData) + Log.d(LOG_KEY, "${this.name}: uploadFile finished") + } + + /** + * Gets the remote backup and properly handle any errors, including uploading the backup + * if no remote file was found. + */ + private suspend fun getRemoteBackup(context: Context): String? { + if (!isReady()) { + Log.d(LOG_KEY, "${this.name}: getRemoteBackup is not ready yet") + return null + } + + val loginData = getLoginData() + if (loginData == null) { + Log.d(LOG_KEY, "${this.name}: getRemoteBackup did not get loginData") + return null + } + + return when (val remoteFile = getRemoteFile(context, loginData)) { + is RemoteFile.NotFound -> { + Log.d(LOG_KEY, "${this.name}: Remote file not found. Uploading file.") + uploadBackup(context) + null + } + + is RemoteFile.Success -> { + Log.d(LOG_KEY, "${this.name}: Remote file found.") + remoteFile.remoteData + } + + is RemoteFile.Error -> { + Log.d(LOG_KEY, "${this.name}: getRemoteFile failed with message: ${remoteFile.message}.") + remoteFile.throwable?.let { error -> logError(error) } + null + } + + else -> { + val message = "${this.name}: Unexpected remote file!" + debugException { message } + Log.d(LOG_KEY, message) + null + } + } + } + + /** + * Gets the remote backup and merges it with the local data. + * Also saves a cached to prevent unnecessary uploading. + * @see getRemoteBackup + * @see mergeBackup + */ + private suspend fun mergeRemoteBackup(context: Context, overwrite: Boolean) { + val remoteData = getRemoteBackup(context) ?: return + lastBackupJson = remoteData + mergeBackup(context, remoteData, overwrite) + } +} + +interface IBackupAPI { data class JSONComparison( val failed: Boolean, val result: JSONCompareResult? @@ -38,173 +216,141 @@ interface BackupAPI { val scheduler: Scheduler> ) - companion object { - const val LOG_KEY = "BACKUP" - const val SYNC_HISTORY_PREFIX = "_hs/" + /** + * Gets the user login info for uploading and downloading the backup. + * If null no backup or download will be run. + */ + suspend fun getLoginData(): LOGIN_DATA? - // Can be called in high frequency (for now) because current implementation uses google - // cloud project per user so there is no way to hit quota. Later we should implement - // some kind of adaptive throttling which will increase decrease throttle time based - // on factors like: live devices, quota limits, etc - val UPLOAD_THROTTLE = 10.seconds - val DOWNLOAD_THROTTLE = 60.seconds - // add to queue may be called frequently - private val ioScope = CoroutineScope(Dispatchers.IO) + /** + * Additional check if the backup operation should be run. + * Return false here to deny any backup work. + */ + suspend fun isReady(): Boolean = true + + /** + * Get the backup file as a string from the remote storage. + * @see RemoteFile.Success + * @see RemoteFile.Error + * @see RemoteFile.NotFound + */ + suspend fun getRemoteFile(context: Context, loginData: LOGIN_DATA): RemoteFile + + suspend fun uploadFile( + context: Context, + backupJson: String, + loginData: LOGIN_DATA + ) + + companion object { + const val SYNC_HISTORY_PREFIX = "_hs/" fun SharedPreferences.logHistoryChanged(path: String, source: BackupUtils.RestoreSource) { edit().putLong("${source.syncPrefix}$path", System.currentTimeMillis()) .apply() } - } - /** - * isActive is recommended to be overridden to verifiy if BackupApi is being used. if manager - * is not set up it won't write sync data. - * @see Scheduler.Companion.createBackupScheduler - * @see SharedPreferences.logHistoryChanged - */ - var isActive: Boolean? - fun updateApiActiveState() { - this.isActive = this.isActive() - } - fun isActive(): Boolean - /** - * Should download data from API and call Context.mergeBackup(incomingData: String). If data - * does not exist on the api uploadSyncData() is recommended to call. Should be called with - * overwrite=true when user ads new account so it would accept changes from API - * @see Context.mergeBackup - * @see uploadSyncData - */ - fun downloadSyncData(overwrite: Boolean) + fun compareJson(old: String, new: String): JSONComparison { + var result: JSONCompareResult? - /** - * Should upload data to API and call Context.createBackup(loginData: LOGIN_DATA) - * @see Context.createBackup(loginData: LOGIN_DATA) - */ - fun uploadSyncData() + val executionTime = measureTimeMillis { + result = try { + JSONCompare.compareJSON(old, new, JSONCompareMode.NON_EXTENSIBLE) + } catch (e: Exception) { + null + } + } + val failed = result?.failed() ?: true + Log.d( + LOG_KEY, + "JSON comparison took $executionTime ms, compareFailed=$failed, result=$result" + ) - fun Context.createBackup(loginData: LOGIN_DATA) - fun Context.mergeBackup(incomingData: String, overwrite: Boolean) { - val newData = DataStore.mapper.readValue(incomingData) - if (overwrite) { - Log.d(LOG_KEY, "overwriting data") - restore(newData) - - return + return JSONComparison(failed, result) } - val keysToUpdate = getKeysToUpdate(getBackup(), newData) - if (keysToUpdate.isEmpty()) { - Log.d(LOG_KEY, "remote data is up to date, sync not needed") - return + private fun getSyncKeys(data: BackupUtils.BackupFile) = + data.syncMeta._Long.orEmpty().filter { it.key.startsWith(SYNC_HISTORY_PREFIX) } + + /** + * Merges the backup data with the app data. + * @param overwrite if true it overwrites all data same as restoring from a backup. + * if false it only updates outdated keys. Should be true on first initialization. + */ + fun mergeBackup(context: Context, incomingData: String, overwrite: Boolean) { + val newData = DataStore.mapper.readValue(incomingData) + if (overwrite) { + Log.d(LOG_KEY, "overwriting data") + context.restore(newData) + + return + } + + val keysToUpdate = getKeysToUpdate(context.getBackup(), newData) + if (keysToUpdate.isEmpty()) { + Log.d(LOG_KEY, "remote data is up to date, sync not needed") + return + } + + + Log.d(LOG_KEY, incomingData) + context.restore(newData, keysToUpdate) } + private fun getKeysToUpdate( + currentData: BackupUtils.BackupFile, + newData: BackupUtils.BackupFile + ): Set { + val currentSync = getSyncKeys(currentData) + val newSync = getSyncKeys(newData) - Log.d(LOG_KEY, incomingData) - restore(newData, keysToUpdate) - } + val changedKeys = newSync.filter { + val localTimestamp = currentSync[it.key] ?: 0L + it.value > localTimestamp + }.keys - var willQueueSoon: Boolean? - var uploadJob: Job? - fun shouldUpdate(changedKey: String, isSettings: Boolean): Boolean - fun addToQueue(changedKey: String, isSettings: Boolean) { + val onlyLocalKeys = currentSync.keys.filter { !newSync.containsKey(it) } + val missingKeys = getAllMissingKeys(currentData, newData) - if (!shouldUpdate(changedKey, isSettings)) { - willQueueSoon = false - Log.d(LOG_KEY, "upload not required, data is same") - return + return (missingKeys + onlyLocalKeys + changedKeys).toSet() } - addToQueueNow() - } - fun addToQueueNow() { - if (uploadJob != null && uploadJob!!.isActive) { - Log.d(LOG_KEY, "upload is canceled, scheduling new") - uploadJob?.cancel() - } + private fun getAllMissingKeys( + old: BackupUtils.BackupFile, + new: BackupUtils.BackupFile + ): List = BackupUtils.RestoreSource + .values() + .filter { it != BackupUtils.RestoreSource.SYNC } + .fold(mutableListOf()) { acc, source -> + acc.addAll(getMissingKeysPrefixed(source, old, new)) + acc + } - uploadJob = ioScope.launchSafe { - willQueueSoon = false - Log.d(LOG_KEY, "upload is running now") - uploadSyncData() - } - } + private fun getMissingKeysPrefixed( + restoreSource: BackupUtils.RestoreSource, + old: BackupUtils.BackupFile, + new: BackupUtils.BackupFile + ): List { + val oldSource = old.getData(restoreSource) + val newSource = new.getData(restoreSource) + val prefixToMatch = restoreSource.syncPrefix - fun compareJson(old: String, new: String): JSONComparison { - var result: JSONCompareResult? - - val executionTime = measureTimeMillis { - result = try { - JSONCompare.compareJSON(old, new, JSONCompareMode.NON_EXTENSIBLE) - } catch (e: Exception) { - null + return listOf( + *getMissing(oldSource._Bool, newSource._Bool), + *getMissing(oldSource._Long, newSource._Long), + *getMissing(oldSource._Float, newSource._Float), + *getMissing(oldSource._Int, newSource._Int), + *getMissing(oldSource._String, newSource._String), + *getMissing(oldSource._StringSet, newSource._StringSet), + ).map { + prefixToMatch + it } } - val failed = result?.failed() ?: true - Log.d(LOG_KEY, "JSON comparison took $executionTime ms, compareFailed=$failed, result=$result") - return JSONComparison(failed, result) + private fun getMissing(old: Map?, new: Map?): Array = + (new.orEmpty().keys - old.orEmpty().keys) + .toTypedArray() } - - fun getKeysToUpdate( - currentData: BackupUtils.BackupFile, - newData: BackupUtils.BackupFile - ): Set { - val currentSync = getSyncKeys(currentData) - val newSync = getSyncKeys(newData) - - val changedKeys = newSync.filter { - val localTimestamp = currentSync[it.key] ?: 0L - it.value > localTimestamp - }.keys - - val onlyLocalKeys = currentSync.keys.filter { !newSync.containsKey(it) } - val missingKeys = getAllMissingKeys(currentData, newData) - - return (missingKeys + onlyLocalKeys + changedKeys).toSet() - } - - private fun getSyncKeys(data: BackupUtils.BackupFile) = - data.syncMeta._Long.orEmpty().filter { it.key.startsWith(SYNC_HISTORY_PREFIX) } - - - private fun getAllMissingKeys( - old: BackupUtils.BackupFile, - new: BackupUtils.BackupFile - ): List = BackupUtils.RestoreSource - .values() - .filter { it != BackupUtils.RestoreSource.SYNC } - .fold(mutableListOf()) { acc, source -> - acc.addAll(getMissingKeysPrefixed(source, old, new)) - acc - } - - private fun getMissingKeysPrefixed( - restoreSource: BackupUtils.RestoreSource, - old: BackupUtils.BackupFile, - new: BackupUtils.BackupFile - ): List { - val oldSource = old.getData(restoreSource) - val newSource = new.getData(restoreSource) - val prefixToMatch = restoreSource.syncPrefix - - return listOf( - *getMissing(oldSource._Bool, newSource._Bool), - *getMissing(oldSource._Long, newSource._Long), - *getMissing(oldSource._Float, newSource._Float), - *getMissing(oldSource._Int, newSource._Int), - *getMissing(oldSource._String, newSource._String), - *getMissing(oldSource._StringSet, newSource._StringSet), - ).map { - prefixToMatch + it - } - } - - - private fun getMissing(old: Map?, new: Map?): Array = - (new.orEmpty().keys - old.orEmpty().keys) - .toTypedArray() - } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt index e17e7ccc..ec0ca6ee 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt @@ -1,9 +1,7 @@ package com.lagradost.cloudstream3.syncproviders -import androidx.annotation.WorkerThread - interface InAppAuthAPI : AuthAPI { - data class LoginData( + data class UserData( val username: String? = null, val password: String? = null, val server: String? = null, @@ -21,10 +19,10 @@ interface InAppAuthAPI : AuthAPI { val storesPasswordInPlainText: Boolean // return true if logged in successfully - suspend fun login(data: LoginData): Boolean + suspend fun login(data: UserData): Boolean // used to fill the UI if you want to edit any data about your login info - fun getLatestLoginData(): LoginData? + fun getUserData(): UserData? } abstract class InAppAuthAPIManager(defIndex: Int) : AccountManager(defIndex), InAppAuthAPI { @@ -47,11 +45,11 @@ abstract class InAppAuthAPIManager(defIndex: Int) : AccountManager(defIndex), In override val icon: Int? = null - override suspend fun login(data: InAppAuthAPI.LoginData): Boolean { + override suspend fun login(data: InAppAuthAPI.UserData): Boolean { throw NotImplementedError() } - override fun getLatestLoginData(): InAppAuthAPI.LoginData? { + override fun getUserData(): InAppAuthAPI.UserData? { throw NotImplementedError() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt index 3788e26a..9eccfc52 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt @@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.syncproviders import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonIgnore -import com.lagradost.cloudstream3.AcraApplication interface InAppOAuth2API : OAuth2API { data class LoginData( @@ -31,36 +30,4 @@ interface InAppOAuth2API : OAuth2API { // used to fill the UI if you want to edit any data about your login info fun getLatestLoginData(): LoginData? -} - -abstract class InAppOAuth2APIManager(defIndex: Int) : AccountManager(defIndex), InAppOAuth2API { - enum class K { - LOGIN_DATA, - IS_READY, - TOKEN, - ; - - val value: String = "data_oauth2_$name" - } - - protected fun storeValue(key: K, value: T) = AcraApplication.setKey( - accountId, key.value, value - ) - - protected fun clearValue(key: K) = AcraApplication.removeKey( - accountId, key.value - ) - - protected inline fun getValue(key: K) = AcraApplication.getKey( - accountId, key.value - ) - - override val requiresLogin = true - override val createAccountUrl = null - - override fun logOut() { - K.values().forEach { clearValue(it) } - removeAccountKeys() - } - } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt index 573ed6f0..f0601be1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt @@ -19,20 +19,16 @@ import com.google.api.services.drive.DriveScopes import com.google.api.services.drive.model.File import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.BackupAPI -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.LOG_KEY import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API -import com.lagradost.cloudstream3.syncproviders.InAppOAuth2APIManager -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.BackupUtils.getBackup +import com.lagradost.cloudstream3.syncproviders.RemoteFile import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe -import com.lagradost.cloudstream3.utils.Scheduler -import kotlinx.coroutines.Job import kotlinx.coroutines.runBlocking import java.io.InputStream import java.util.Date @@ -43,10 +39,12 @@ import java.util.Date * * | State | Priority | Description * |---------:|:--------:|--------------------------------------------------------------------- - * | Someday | 4 | Add button to manually trigger sync * | Someday | 5 | Choose what should be synced and recheck `invalidKeys` in createBackupScheduler * | Someday | 3 | Add option to use proper OAuth through Google Services One Tap * | Someday | 5 | Encrypt data on Drive (low priority) + * | Someday | 4 | Make local sync + * | Someday | 4 | Make sync button more interactive + * | Solved | 4 | Add button to manually trigger sync * | Solved | 1 | Racing conditions when multiple devices in use * | Solved | 2 | Restoring backup should update view models * | Solved | 1 | Check if data was really changed when calling backupscheduler.work then @@ -59,11 +57,7 @@ import java.util.Date * | Solved | 3 | Move "https://chiff.github.io/cloudstream-sync/google-drive" */ class GoogleDriveApi(index: Int) : - InAppOAuth2APIManager(index), - BackupAPI { - ///////////////////////////////////////// - ///////////////////////////////////////// - // Setup + BackupAPI(index), InAppOAuth2API { override val key = "gdrive" override val redirectUrl = "oauth/google-drive" @@ -71,6 +65,8 @@ class GoogleDriveApi(index: Int) : override val name = "Google Drive" override val icon = R.drawable.ic_baseline_add_to_drive_24 + override val requiresLogin = true + override val createAccountUrl = null override val requiresFilename = true override val requiresSecret = true override val requiresClientId = true @@ -79,17 +75,31 @@ class GoogleDriveApi(index: Int) : "https://recloudstream.github.io/cloudstream-sync/google-drive" override val infoUrl = "https://recloudstream.github.io/cloudstream-sync/google-drive/help.html" - override var isActive: Boolean? = false - override var willQueueSoon: Boolean? = false - override var uploadJob: Job? = null - private var tempAuthFlow: AuthorizationCodeFlow? = null - private var lastBackupJson: String? = null companion object { const val GOOGLE_ACCOUNT_INFO_KEY = "google_account_info_key" } + private fun storeValue(key: K, value: T) = setKey( + accountId, key.value, value + ) + + private fun clearValue(key: K) = removeKey(accountId, key.value) + + private inline fun getValue(key: K) = getKey( + accountId, key.value + ) + + enum class K { + LOGIN_DATA, + IS_READY, + TOKEN, + ; + + val value: String = "data_oauth2_$name" + } + ///////////////////////////////////////// ///////////////////////////////////////// // OAuth2API implementation @@ -125,24 +135,17 @@ class GoogleDriveApi(index: Int) : storeValue(K.TOKEN, googleTokenResponse) storeValue(K.IS_READY, true) - updateApiActiveState() - runDownloader(runNow = true, overwrite = true) + + // First launch overwrites + scheduleDownload(runNow = true, overwrite = true) tempAuthFlow = null return true } - ///////////////////////////////////////// - ///////////////////////////////////////// - // InAppOAuth2APIManager implementation override suspend fun initialize() { - updateApiActiveState() - if (isActive != true) { - return - } - ioSafe { - runDownloader(true) + scheduleDownload(true) } } @@ -179,7 +182,7 @@ class GoogleDriveApi(index: Int) : } override fun loginInfo(): AuthAPI.LoginInfo? { - val driveService = getDriveService() ?: return null + val driveService = getLatestLoginData()?.let { getDriveService(it) } ?: return null val userInfo = runBlocking { getUserInfo(driveService) } ?: getBlankUser() @@ -210,7 +213,6 @@ class GoogleDriveApi(index: Int) : this.tempAuthFlow = authFlow try { - updateApiActiveState() registerAccount() val url = authFlow.newAuthorizationUrl().setRedirectUri(data.redirectUrl).build() @@ -229,25 +231,68 @@ class GoogleDriveApi(index: Int) : return getValue(K.LOGIN_DATA) } + override suspend fun getLoginData(): InAppOAuth2API.LoginData? { + return getLatestLoginData() + } + ///////////////////////////////////////// ///////////////////////////////////////// // BackupAPI implementation - override fun isActive(): Boolean { + override suspend fun isReady(): Boolean { + val loginData = getLatestLoginData() return getValue(K.IS_READY) == true && loginInfo() != null && - getDriveService() != null && - AcraApplication.context != null && - getLatestLoginData() != null + loginData != null && + getDriveService(loginData) != null && + AcraApplication.context != null } - override fun Context.createBackup(loginData: InAppOAuth2API.LoginData) { - val drive = getDriveService() ?: return + override suspend fun getRemoteFile( + context: Context, + loginData: InAppOAuth2API.LoginData + ): RemoteFile { + val drive = + getDriveService(loginData) ?: return RemoteFile.Error("Cannot get drive service") + + val existingFileId = getOrFindExistingSyncFileId(drive, loginData) + val existingFile = if (existingFileId != null) { + try { + drive.files().get(existingFileId) + } catch (e: Exception) { + Log.e(LOG_KEY, "Could not find file for id $existingFileId", e) + null + } + } else { + null + } + + if (existingFile != null) { + try { + val inputStream: InputStream = existingFile.executeMediaAsInputStream() + val content: String = inputStream.bufferedReader().use { it.readText() } + Log.d(LOG_KEY, "downloadSyncData merging") + return RemoteFile.Success(content) + } catch (e: Exception) { + Log.e(LOG_KEY, "download failed", e) + } + } + + // if failed + Log.d(LOG_KEY, "downloadSyncData file not exists") + return RemoteFile.NotFound() + } + + override suspend fun uploadFile( + context: Context, + backupJson: String, + loginData: InAppOAuth2API.LoginData + ) { + val drive = getDriveService(loginData) ?: return val fileName = loginData.fileName val syncFileId = loginData.syncFileId val ioFile = java.io.File(AcraApplication.context?.cacheDir, fileName) - lastBackupJson = getBackup().toJson() - ioFile.writeText(lastBackupJson!!) + ioFile.writeText(backupJson) val fileMetadata = File() fileMetadata.name = fileName @@ -277,40 +322,6 @@ class GoogleDriveApi(index: Int) : } } - override fun downloadSyncData(overwrite: Boolean) { - val ctx = AcraApplication.context ?: return - val drive = getDriveService() ?: return - val loginData = getLatestLoginData() ?: return - - val existingFileId = getOrFindExistingSyncFileId(drive, loginData) - val existingFile = if (existingFileId != null) { - try { - drive.files().get(existingFileId) - } catch (e: Exception) { - Log.e(LOG_KEY, "Could not find file for id $existingFileId", e) - null - } - } else { - null - } - - if (existingFile != null) { - try { - val inputStream: InputStream = existingFile.executeMediaAsInputStream() - val content: String = inputStream.bufferedReader().use { it.readText() } - Log.d(LOG_KEY, "downloadSyncData merging") - ctx.mergeBackup(content, overwrite) - return - } catch (e: Exception) { - Log.e(LOG_KEY, "download failed", e) - } - } - - // if failed - Log.d(LOG_KEY, "downloadSyncData file not exists") - uploadSyncData() - } - private fun getOrFindExistingSyncFileId( drive: Drive, loginData: InAppOAuth2API.LoginData @@ -342,40 +353,8 @@ class GoogleDriveApi(index: Int) : return null } - override fun uploadSyncData() { - val canUpload = getValue(K.IS_READY) - if (canUpload != true) { - Log.d(LOG_KEY, "uploadSyncData is not ready yet") - return - } - - val ctx = AcraApplication.context - val loginData = getLatestLoginData() - - if (ctx == null) { - Log.d(LOG_KEY, "uploadSyncData cannot run (ctx)") - return - } - - - if (loginData == null) { - Log.d(LOG_KEY, "uploadSyncData cannot run (loginData)") - return - } - - Log.d(LOG_KEY, "uploadSyncData will run") - ctx.createBackup(loginData) - } - - override fun shouldUpdate(changedKey: String, isSettings: Boolean): Boolean { - val ctx = AcraApplication.context ?: return false - - val newBackup = ctx.getBackup().toJson() - return compareJson(lastBackupJson ?: "", newBackup).failed - } - - private fun getDriveService(): Drive? { - val credential = getCredentialsFromStore() ?: return null + private fun getDriveService(loginData: InAppOAuth2API.LoginData): Drive? { + val credential = getCredentialsFromStore(loginData) ?: return null return Drive.Builder( GAPI.HTTP_TRANSPORT, @@ -386,41 +365,12 @@ class GoogleDriveApi(index: Int) : .build() } - ///////////////////////////////////////// - ///////////////////////////////////////// - // Internal - private val continuousDownloader = Scheduler( - BackupAPI.DOWNLOAD_THROTTLE.inWholeMilliseconds, - onWork = { overwrite -> - if (uploadJob?.isActive == true || willQueueSoon == true) { - uploadJob!!.invokeOnCompletion { - Log.d(LOG_KEY, "upload is running, reschedule download") - runDownloader(false, overwrite == true) - } - } else { - Log.d(LOG_KEY, "downloadSyncData will run") - ioSafe { - downloadSyncData(overwrite == true) - } - runDownloader() - } - } - ) - private fun runDownloader(runNow: Boolean = false, overwrite: Boolean = false) { - if (runNow) { - continuousDownloader.workNow(overwrite) - } else { - continuousDownloader.work(overwrite) - } - } - - private fun getCredentialsFromStore(): Credential? { - val loginDate = getLatestLoginData() + private fun getCredentialsFromStore(loginData: InAppOAuth2API.LoginData): Credential? { val token = getValue(K.TOKEN) - val credential = if (loginDate != null && token != null) { - GAPI.getCredentials(token, loginDate) + val credential = if (token != null) { + GAPI.getCredentials(token, loginData) } else { return null } @@ -437,6 +387,11 @@ class GoogleDriveApi(index: Int) : return credential } + override fun logOut() { + K.values().forEach { clearValue(it) } + removeAccountKeys() + } + ///////////////////////////////////////// ///////////////////////////////////////// // Google API integration helper diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt index 4030649d..ceca952d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt @@ -90,9 +90,9 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi return null } - override fun getLatestLoginData(): InAppAuthAPI.LoginData? { + override fun getUserData(): InAppAuthAPI.UserData? { val current = getAuthKey() ?: return null - return InAppAuthAPI.LoginData(username = current.user, current.pass) + return InAppAuthAPI.UserData(username = current.user, current.pass) } /* @@ -143,7 +143,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi return false } - override suspend fun login(data: InAppAuthAPI.LoginData): Boolean { + override suspend fun login(data: InAppAuthAPI.UserData): Boolean { val username = data.username ?: throw ErrorLoadingException("Requires Username") val password = data.password ?: throw ErrorLoadingException("Requires Password") switchToNewAccount() diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/PcloudApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/PcloudApi.kt new file mode 100644 index 00000000..f7a25561 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/PcloudApi.kt @@ -0,0 +1,198 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import android.content.Context +import android.util.Base64 +import androidx.fragment.app.FragmentActivity +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.debugPrint +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.BackupAPI +import com.lagradost.cloudstream3.syncproviders.OAuth2API +import com.lagradost.cloudstream3.syncproviders.RemoteFile +import com.lagradost.cloudstream3.utils.AppUtils.splitQuery +import com.lagradost.nicehttp.NiceFile +import java.io.File +import java.net.URL +import java.security.SecureRandom + +class PcloudApi(index: Int) : OAuth2API, + BackupAPI(index) { + companion object { + const val PCLOUD_TOKEN_KEY: String = "pcloud_token" + const val PCLOUD_HOST_KEY: String = "pcloud_host" + const val PCLOUD_USERNAME_KEY: String = "pcloud_username" + const val PCLOUD_FILE_ID_KEY = "pcloud_file_id" + const val FILENAME = "cloudstream-backup.json" + +// data class OAuthResponse( +// @JsonProperty("access_token") val access_token: String, +// @JsonProperty("token_type") val token_type: String, +// @JsonProperty("uid") val uid: Int, +// +// @JsonProperty("result") val result: Int, +// @JsonProperty("error") val error: String? +// ) + + /** https://docs.pcloud.com/methods/file/uploadfile.html */ + data class FileUpload( + @JsonProperty("result") val result: Int, + // @JsonProperty("fileids") val fileids: List, + @JsonProperty("metadata") val metadata: List, + ) { + data class FileMetaData( + val fileid: Long, + ) + } + + /** https://docs.pcloud.com/methods/streaming/getfilelink.html */ + data class FileLink( + @JsonProperty("result") val result: Int, + @JsonProperty("path") val path: String, + @JsonProperty("hosts") val hosts: List + ) { + fun getBestLink(): String? { + val host = hosts.firstOrNull() ?: return null + return "https://$host$path" + } + } + + data class UserInfo( + @JsonProperty("email") val email: String, + @JsonProperty("userid") val userid: String + ) + } + + + override val name = "pCloud" + override val icon = R.drawable.ic_baseline_add_to_drive_24 + override val requiresLogin = true + override val createAccountUrl = "https://my.pcloud.com/#page=login" + override val idPrefix = "pcloud" + + override fun loginInfo(): AuthAPI.LoginInfo? { + // Guarantee token + if (getKey(accountId, PCLOUD_TOKEN_KEY).isNullOrBlank()) return null + + val username = getKey(accountId, PCLOUD_USERNAME_KEY) ?: return null + return AuthAPI.LoginInfo( + name = username, + accountIndex = accountIndex + ) + } + + override fun logOut() { + removeAccountKeys() + } + + override suspend fun initialize() { + scheduleDownload(true) + } + + val url = "https://pcloud.com/" + override val key = "" // TODO FIX + override val redirectUrl = "pcloud" + + override suspend fun handleRedirect(url: String): Boolean { + // redirect_uri#access_token=XXXXX&token_type=bearer&uid=YYYYYY&state=ZZZZZZ&locationid=[1 or 2]&hostname=[api.pcloud.com or eapi.pcloud.com] + val query = splitQuery(URL(url.replace(appString, "https").replace("#", "?"))) + + if (query["state"] != state || state.isBlank()) { + return false + } + state = "" + + val token = query["access_token"] ?: return false + val hostname = query["hostname"] ?: return false + + val userInfo = app.get( + "https://$hostname/userinfo", + headers = mapOf("Authorization" to "Bearer $token") + ).parsedSafe() ?: return false + + switchToNewAccount() + setKey(accountId, PCLOUD_TOKEN_KEY, token) + setKey(accountId, PCLOUD_USERNAME_KEY, userInfo.email.substringBeforeLast("@")) + setKey(accountId, PCLOUD_HOST_KEY, hostname) + registerAccount() + + scheduleDownload(runNow = true, overwrite = true) + return true + } + + private fun getToken(): String? { + return getKey(accountId, PCLOUD_TOKEN_KEY) + } + + private val mainUrl: String + get() = getKey(accountId, PCLOUD_HOST_KEY)?.let { "https://$it" } + ?: "https://api.pcloud.com" + private val authHeaders: Map + get() = getToken()?.let { token -> mapOf("Authorization" to "Bearer $token") } ?: mapOf() + + private fun getFileId(): Long? = getKey(accountId, PCLOUD_FILE_ID_KEY) + + private var state = "" + override fun authenticate(activity: FragmentActivity?) { + val secureRandom = SecureRandom() + val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128 + secureRandom.nextBytes(codeVerifierBytes) + state = + Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=').replace("+", "-") + .replace("/", "_").replace("\n", "") + val codeChallenge = state + + val request = + "https://my.pcloud.com/oauth2/authorize?response_type=token&client_id=$key&state=$codeChallenge&redirect_uri=$appString://$redirectUrl" + openBrowser(request, activity) + } + + override suspend fun getLoginData(): String? { + return getToken() + } + + override suspend fun uploadFile( + context: Context, + backupJson: String, + loginData: String + ) { + val ioFile = File(AcraApplication.context?.cacheDir, FILENAME) + ioFile.writeText(backupJson) + + val uploadedFile = app.post( + "$mainUrl/uploadfile", + files = listOf( + NiceFile(ioFile), + NiceFile("nopartial", "1") + ), + headers = authHeaders + ).parsedSafe() + + debugPrint { "${this.name}: Uploaded file: $uploadedFile" } + + val fileId = uploadedFile?.metadata?.firstOrNull()?.fileid ?: return + setKey(accountId, PCLOUD_FILE_ID_KEY, fileId) + } + + override suspend fun getRemoteFile( + context: Context, + loginData: String + ): RemoteFile { + val fileId = getFileId() ?: return RemoteFile.NotFound() + val fileLink = app.post( + "$mainUrl/getfilelink", data = mapOf( + "fileid" to fileId.toString() + ), + referer = "https://pcloud.com", + headers = authHeaders + ).parsedSafe() + + val url = fileLink?.getBestLink() ?: return RemoteFile.NotFound() + return RemoteFile.Success(app.get(url).text) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index f3325227..4295ff5d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -18,6 +18,7 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniList import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.googleDriveApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.pcloudApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI @@ -140,7 +141,8 @@ class SettingsAccount : PreferenceFragmentCompat() { R.string.anilist_key to aniListApi, R.string.simkl_key to simklApi, R.string.opensubtitles_key to openSubtitlesApi, - R.string.gdrive_key to googleDriveApi + R.string.gdrive_key to googleDriveApi, + R.string.pcloud_key to pcloudApi ) for ((key, api) in syncApis) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt index 4895b0d2..eb5b2401 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt @@ -9,6 +9,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView +import android.widget.Toast import androidx.annotation.StringRes import androidx.core.view.children import androidx.core.view.isVisible @@ -17,11 +18,15 @@ import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import com.google.android.material.appbar.MaterialToolbar +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.MainSettingsBinding import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.BackupApis import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers +import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.ui.home.HomeFragment +import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.setImage @@ -206,6 +211,21 @@ class SettingsFragment : Fragment() { } } + // Only show the button if the api does not require login, requires login, but the user is logged in + forceSyncDataBtt.isVisible = BackupApis.any { api -> + api !is AuthAPI || api.loginInfo() != null + } + + forceSyncDataBtt.setOnClickListener { + BackupApis.forEach { api -> + api.scheduleUpload() + } + showToast(activity, txt(R.string.syncing_data), Toast.LENGTH_SHORT) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + forceSyncDataBtt.tooltipText = txt(R.string.sync_data).asString(forceSyncDataBtt.context) + } + // Default focus on TV if (isTrueTv) { settingsGeneral.requestFocus() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt index 52a61bf8..1917546b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt @@ -27,7 +27,7 @@ class InAppAuthDialogBuilder( override fun onLogin(dialog: AlertDialog): Unit = with(binding) { // if (activity == null) throw IllegalStateException("Login should be called after validation") - val loginData = InAppAuthAPI.LoginData( + val userData = InAppAuthAPI.UserData( username = if (api.requiresUsername) loginUsernameInput.text?.toString() else null, password = if (api.requiresPassword) loginPasswordInput.text?.toString() else null, email = if (api.requiresEmail) loginEmailInput.text?.toString() else null, @@ -36,7 +36,7 @@ class InAppAuthDialogBuilder( ioSafe { val isSuccessful = try { - api.login(loginData) + api.login(userData) } catch (e: Exception) { logError(e) false @@ -93,7 +93,7 @@ class InAppAuthDialogBuilder( override fun handleStoresPasswordInPlainText(dialog: AlertDialog): Unit = with(binding) { if (!api.storesPasswordInPlainText) return - api.getLatestLoginData()?.let { data -> + api.getUserData()?.let { data -> loginEmailInput.setText(data.email ?: "") loginServerInput.setText(data.server ?: "") loginUsernameInput.setText(data.username ?: "") diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 4e54095e..f75e8315 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -3,8 +3,6 @@ package com.lagradost.cloudstream3.utils import android.annotation.SuppressLint import android.content.Context import android.net.Uri -import android.os.Build -import android.provider.MediaStore import android.util.Log import android.widget.Toast import androidx.activity.result.ActivityResultLauncher @@ -20,11 +18,12 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.plugins.PLUGINS_KEY import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL import com.lagradost.cloudstream3.syncproviders.BackupAPI -import com.lagradost.cloudstream3.syncproviders.InAppOAuth2APIManager +import com.lagradost.cloudstream3.syncproviders.IBackupAPI import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_TOKEN_KEY import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_UNIXTIME_KEY import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_USER_KEY +import com.lagradost.cloudstream3.syncproviders.providers.GoogleDriveApi import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_REFRESH_TOKEN_KEY import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_TOKEN_KEY @@ -55,7 +54,7 @@ object BackupUtils { DATA, SETTINGS, SYNC; val prefix = "$name/" - val syncPrefix = "${BackupAPI.SYNC_HISTORY_PREFIX}$prefix" + val syncPrefix = "${IBackupAPI.SYNC_HISTORY_PREFIX}$prefix" } /** @@ -72,8 +71,8 @@ object BackupUtils { MAL_CACHED_LIST, MAL_UNIXTIME_KEY, MAL_USER_KEY, - InAppOAuth2APIManager.K.TOKEN.value, - InAppOAuth2APIManager.K.IS_READY.value, + GoogleDriveApi.K.TOKEN.value, + GoogleDriveApi.K.IS_READY.value, // The plugins themselves are not backed up PLUGINS_KEY, @@ -81,6 +80,8 @@ object BackupUtils { OPEN_SUBTITLES_USER_KEY, "nginx_user", // Nginx user key + DOWNLOAD_HEADER_CACHE, + DOWNLOAD_EPISODE_CACHE ) /** false if blacklisted key */ @@ -328,7 +329,7 @@ object BackupUtils { var prefixToRemove = prefixToMatch if (restoreSource == RestoreSource.SYNC) { - prefixToMatch = BackupAPI.SYNC_HISTORY_PREFIX + prefixToMatch = IBackupAPI.SYNC_HISTORY_PREFIX prefixToRemove = "" } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index dc9f1835..1c99f0bf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -12,7 +12,8 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.setKeyClass import com.lagradost.cloudstream3.mvvm.logError import kotlin.reflect.KClass import kotlin.reflect.KProperty -import com.lagradost.cloudstream3.syncproviders.BackupAPI +import com.lagradost.cloudstream3.syncproviders.IBackupAPI +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe const val DOWNLOAD_HEADER_CACHE = "download_header_cache" @@ -31,6 +32,7 @@ class PreferenceDelegate( val key: String, val default: T //, private val klass: KClass ) { private val klass: KClass = default::class + // simple cache to make it not get the key every time it is accessed, however this requires // that ONLY this changes the key private var cache: T? = null @@ -79,6 +81,7 @@ object DataStore { fun getFolderName(folder: String, path: String): String { return "${folder}/${path}" } + fun Context.setKeyRaw(path: String, value: T, restoreSource: BackupUtils.RestoreSource) { try { val editor = when (restoreSource) { @@ -100,7 +103,8 @@ object DataStore { logError(e) } } - fun Context.removeKeyRaw(path: String, restoreSource: BackupUtils.RestoreSource) { + + fun Context.removeKeyRaw(path: String, restoreSource: BackupUtils.RestoreSource) { try { when (restoreSource) { BackupUtils.RestoreSource.DATA -> getSharedPrefs().edit() @@ -143,15 +147,17 @@ object DataStore { editor.remove(path) editor.apply() - backupScheduler.work( - BackupAPI.PreferencesSchedulerData( - getSyncPrefs(), - path, - oldValueExists, - false, - BackupUtils.RestoreSource.DATA + ioSafe { + backupScheduler.work( + IBackupAPI.PreferencesSchedulerData( + getSyncPrefs(), + path, + oldValueExists, + false, + BackupUtils.RestoreSource.DATA + ) ) - ) + } } } catch (e: Exception) { logError(e) @@ -176,15 +182,17 @@ object DataStore { editor.putString(path, newValue) editor.apply() - backupScheduler.work( - BackupAPI.PreferencesSchedulerData( - getSyncPrefs(), - path, - oldValue, - newValue, - BackupUtils.RestoreSource.DATA + ioSafe { + backupScheduler.work( + IBackupAPI.PreferencesSchedulerData( + getSyncPrefs(), + path, + oldValue, + newValue, + BackupUtils.RestoreSource.DATA + ) ) - ) + } } catch (e: Exception) { logError(e) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt index 26f982af..3bc6ec6d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt @@ -6,17 +6,20 @@ import android.os.Looper import android.util.Log import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.BackupAPI -import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.logHistoryChanged +import com.lagradost.cloudstream3.syncproviders.IBackupAPI +import com.lagradost.cloudstream3.syncproviders.IBackupAPI.Companion.logHistoryChanged import com.lagradost.cloudstream3.ui.home.HOME_BOOKMARK_VALUE_LIST import com.lagradost.cloudstream3.ui.player.PLAYBACK_SPEED_KEY import com.lagradost.cloudstream3.ui.player.RESIZE_MODE_KEY import com.lagradost.cloudstream3.utils.BackupUtils.nonTransferableKeys +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import kotlinx.coroutines.runBlocking class Scheduler( private val throttleTimeMs: Long, - private val onWork: (INPUT?) -> Unit, - private val beforeWork: ((INPUT?) -> Unit)? = null, - private val canWork: ((INPUT?) -> Boolean)? = null + private val onWork: suspend (INPUT) -> Unit, + private val beforeWork: (suspend (INPUT?) -> Unit)? = null, + private val canWork: (suspend (INPUT) -> Boolean)? = null ) { companion object { var SCHEDULER_ID = 1 @@ -28,50 +31,61 @@ class Scheduler( DOWNLOAD_HEADER_CACHE, PLAYBACK_SPEED_KEY, HOME_BOOKMARK_VALUE_LIST, - RESIZE_MODE_KEY + RESIZE_MODE_KEY, + ) + private val invalidUploadTriggerKeysRegex = listOf( + // These trigger automatically every time a show is opened, way too often. + Regex("""^\d+/$RESULT_SEASON/"""), + Regex("""^\d+/$RESULT_EPISODE/"""), + Regex("""^\d+/$RESULT_DUB/"""), ) - fun createBackupScheduler() = Scheduler>( + fun createBackupScheduler() = Scheduler>( BackupAPI.UPLOAD_THROTTLE.inWholeMilliseconds, onWork = { input -> - if (input == null) { - throw IllegalStateException() - } - - AccountManager.BackupApis.forEach { - it.addToQueue( + AccountManager.BackupApis.forEach { api -> + api.scheduleUpload( input.storeKey, input.source == BackupUtils.RestoreSource.SETTINGS ) } }, - beforeWork = { - AccountManager.BackupApis.filter { - it.isActive == true + beforeWork = { _ -> + AccountManager.BackupApis.filter { api -> + api.isReady() }.forEach { - it.willQueueSoon = true + it.willUploadSoon = true } }, canWork = { input -> - if (input == null) { - throw IllegalStateException() - } - - val hasSomeActiveManagers = AccountManager.BackupApis.any { it.isActive == true } + val hasSomeActiveManagers = AccountManager.BackupApis.any { it.isReady() } if (!hasSomeActiveManagers) { return@Scheduler false } - val hasInvalidKey = invalidUploadTriggerKeys.contains(input.storeKey) - if (hasInvalidKey) { - return@Scheduler false - } - val valueDidNotChange = input.oldValue == input.newValue if (valueDidNotChange) { return@Scheduler false } + // Do not sync account preferences + val isAccountKey = AccountManager.accountManagers.any { + input.storeKey.startsWith("${it.accountId}/") + } + if (isAccountKey) { + return@Scheduler false + } + + val hasInvalidKey = invalidUploadTriggerKeys.any { key -> + input.storeKey.startsWith(key) + } || invalidUploadTriggerKeysRegex.any { keyRegex -> + input.storeKey.contains(keyRegex) + } + + if (hasInvalidKey) { + return@Scheduler false + } + input.syncPrefs.logHistoryChanged(input.storeKey, input.source) return@Scheduler true } @@ -83,27 +97,29 @@ class Scheduler( fun SharedPreferences.attachBackupListener( source: BackupUtils.RestoreSource = BackupUtils.RestoreSource.SETTINGS, syncPrefs: SharedPreferences - ): BackupAPI.SharedPreferencesWithListener { + ): IBackupAPI.SharedPreferencesWithListener { val scheduler = createBackupScheduler() var lastValue = all registerOnSharedPreferenceChangeListener { sharedPreferences, storeKey -> - scheduler.work( - BackupAPI.PreferencesSchedulerData( - syncPrefs, - storeKey, - lastValue[storeKey], - sharedPreferences.all[storeKey], - source + ioSafe { + scheduler.work( + IBackupAPI.PreferencesSchedulerData( + syncPrefs, + storeKey, + lastValue[storeKey], + sharedPreferences.all[storeKey], + source + ) ) - ) + } lastValue = sharedPreferences.all } - return BackupAPI.SharedPreferencesWithListener(this, scheduler) + return IBackupAPI.SharedPreferencesWithListener(this, scheduler) } - fun SharedPreferences.attachBackupListener(syncPrefs: SharedPreferences): BackupAPI.SharedPreferencesWithListener { + fun SharedPreferences.attachBackupListener(syncPrefs: SharedPreferences): IBackupAPI.SharedPreferencesWithListener { return attachBackupListener(BackupUtils.RestoreSource.SETTINGS, syncPrefs) } } @@ -112,7 +128,7 @@ class Scheduler( private val handler = Handler(Looper.getMainLooper()) private var runnable: Runnable? = null - fun work(input: INPUT? = null): Boolean { + suspend fun work(input: INPUT): Boolean { if (canWork?.invoke(input) == false) { // Log.d(LOG_KEY, "[$id] cannot schedule [${input}]") return false @@ -125,7 +141,7 @@ class Scheduler( return true } - fun workNow(input: INPUT? = null): Boolean { + suspend fun workNow(input: INPUT): Boolean { if (canWork?.invoke(input) == false) { Log.d(BackupAPI.LOG_KEY, "[$id] cannot run immediate [${input}]") return false @@ -147,13 +163,20 @@ class Scheduler( } } - private fun throttle(input: INPUT?) { + /** + * Prevents spamming the service by only allowing one job every throttleTimeMs + * @see throttleTimeMs + */ + private suspend fun throttle(input: INPUT) { stop() runnable = Runnable { Log.d(BackupAPI.LOG_KEY, "[$id] schedule success") - onWork(input) + runBlocking { + onWork(input) + } + }.also { run -> + handler.postDelayed(run, throttleTimeMs) } - handler.postDelayed(runnable!!, throttleTimeMs) } } \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_cloud_24.xml b/app/src/main/res/drawable/baseline_cloud_24.xml new file mode 100644 index 00000000..b3c6d33c --- /dev/null +++ b/app/src/main/res/drawable/baseline_cloud_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/main_settings.xml b/app/src/main/res/layout/main_settings.xml index 387f98fa..5115090e 100644 --- a/app/src/main/res/layout/main_settings.xml +++ b/app/src/main/res/layout/main_settings.xml @@ -41,9 +41,10 @@ + + + mal_key opensubtitles_key gdrive_key + pcloud_key nginx_key password123 MyCoolUsername @@ -696,5 +697,7 @@ Oauth redirect url (optional) https://recloudstream.github.io/cloudstream-sync/google-drive Info + Sync data + Syncing data diff --git a/app/src/main/res/xml/settings_account.xml b/app/src/main/res/xml/settings_account.xml index e7a45f75..c798184d 100644 --- a/app/src/main/res/xml/settings_account.xml +++ b/app/src/main/res/xml/settings_account.xml @@ -17,6 +17,10 @@ + +