Add Pcloud and refactor

This commit is contained in:
CranberrySoup 2023-10-29 17:38:01 +01:00
parent 923e93a692
commit 4b7fc62237
18 changed files with 754 additions and 411 deletions

View file

@ -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 {

View file

@ -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<OAuth2API>(
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<BackupAPI<*>>(
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
}

View file

@ -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<LOGIN_DATA> {
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<LOGIN_DATA>(defIndex: Int) : IBackupAPI<LOGIN_DATA>,
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<Boolean>(
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<LOGIN_DATA> {
data class JSONComparison(
val failed: Boolean,
val result: JSONCompareResult?
@ -38,173 +216,141 @@ interface BackupAPI<LOGIN_DATA> {
val scheduler: Scheduler<PreferencesSchedulerData<*>>
)
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<BackupUtils.BackupFile>(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<BackupUtils.BackupFile>(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<String> {
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<String> = 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<String> {
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<String, *>?, new: Map<String, *>?): Array<String> =
(new.orEmpty().keys - old.orEmpty().keys)
.toTypedArray()
}
fun getKeysToUpdate(
currentData: BackupUtils.BackupFile,
newData: BackupUtils.BackupFile
): Set<String> {
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<String> = 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<String> {
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<String, *>?, new: Map<String, *>?): Array<String> =
(new.orEmpty().keys - old.orEmpty().keys)
.toTypedArray()
}

View file

@ -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()
}

View file

@ -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 <T> storeValue(key: K, value: T) = AcraApplication.setKey(
accountId, key.value, value
)
protected fun clearValue(key: K) = AcraApplication.removeKey(
accountId, key.value
)
protected inline fun <reified T : Any> getValue(key: K) = AcraApplication.getKey<T>(
accountId, key.value
)
override val requiresLogin = true
override val createAccountUrl = null
override fun logOut() {
K.values().forEach { clearValue(it) }
removeAccountKeys()
}
}

View file

@ -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<InAppOAuth2API.LoginData> {
/////////////////////////////////////////
/////////////////////////////////////////
// Setup
BackupAPI<InAppOAuth2API.LoginData>(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 <T> storeValue(key: K, value: T) = setKey(
accountId, key.value, value
)
private fun clearValue(key: K) = removeKey(accountId, key.value)
private inline fun <reified T : Any> getValue(key: K) = getKey<T>(
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<Boolean>(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<Boolean>(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<Boolean>(
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<TokenResponse>(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

View file

@ -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()

View file

@ -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<String>(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<Int>,
@JsonProperty("metadata") val metadata: List<FileMetaData>,
) {
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<String>
) {
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<String>(accountId, PCLOUD_TOKEN_KEY).isNullOrBlank()) return null
val username = getKey<String>(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<UserInfo>() ?: 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<String>(accountId, PCLOUD_HOST_KEY)?.let { "https://$it" }
?: "https://api.pcloud.com"
private val authHeaders: Map<String, String>
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<FileUpload>()
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<FileLink>()
val url = fileLink?.getBestLink() ?: return RemoteFile.NotFound()
return RemoteFile.Success(app.get(url).text)
}
}

View file

@ -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) {

View file

@ -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()

View file

@ -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 ?: "")

View file

@ -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 = ""
}

View file

@ -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<T : Any>(
val key: String, val default: T //, private val klass: KClass<T>
) {
private val klass: KClass<out T> = 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 <T> 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)
}

View file

@ -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<INPUT>(
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<INPUT>(
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<BackupAPI.PreferencesSchedulerData<*>>(
fun createBackupScheduler() = Scheduler<IBackupAPI.PreferencesSchedulerData<*>>(
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<INPUT>(
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<INPUT>(
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<INPUT>(
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<INPUT>(
}
}
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)
}
}

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="?attr/white"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96z"/>
</vector>

View file

@ -41,9 +41,10 @@
<TextView
android:id="@+id/settings_profile_text"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="1"
android:gravity="center_vertical"
android:paddingStart="10dp"
android:paddingEnd="10dp"
@ -51,6 +52,16 @@
android:textSize="18sp"
android:textStyle="normal"
tools:text="Hello world" />
<ImageView
android:id="@+id/force_sync_data_btt"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_vertical"
android:contentDescription="@string/sync_data"
android:padding="8dp"
android:src="@drawable/quantum_ic_refresh_white_24" />
</LinearLayout>
<TextView

View file

@ -455,6 +455,7 @@
<string name="mal_key" translatable="false">mal_key</string>
<string name="opensubtitles_key" translatable="false">opensubtitles_key</string>
<string name="gdrive_key" translatable="false">gdrive_key</string>
<string name="pcloud_key" translatable="false">pcloud_key</string>
<string name="nginx_key" translatable="false">nginx_key</string>
<string name="example_password">password123</string>
<string name="example_username">MyCoolUsername</string>
@ -696,5 +697,7 @@
<string name="example_login_redirect_url_full">Oauth redirect url (optional)</string>
<string name="example_redirect_url" translatable="false">https://recloudstream.github.io/cloudstream-sync/google-drive</string>
<string name="info_button">Info</string>
<string name="sync_data">Sync data</string>
<string name="syncing_data">Syncing data</string>
</resources>

View file

@ -17,6 +17,10 @@
<Preference
android:key="@string/gdrive_key"
android:icon="@drawable/ic_baseline_add_to_drive_24" />
<Preference
android:key="@string/pcloud_key"
android:icon="@drawable/baseline_cloud_24" />
</PreferenceCategory>
<!-- <Preference-->
<!-- android:key="@string/nginx_key"-->