mirror of
https://github.com/recloudstream/cloudstream.git
synced 2024-08-15 01:53:11 +00:00
Add Pcloud and refactor
This commit is contained in:
parent
923e93a692
commit
4b7fc62237
18 changed files with 754 additions and 411 deletions
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 ?: "")
|
||||
|
|
|
@ -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 = ""
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
5
app/src/main/res/drawable/baseline_cloud_24.xml
Normal file
5
app/src/main/res/drawable/baseline_cloud_24.xml
Normal 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>
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"-->
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue