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.appStringResumeWatching
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringSearch
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringSearch
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths
|
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.APIRepository
|
||||||
import com.lagradost.cloudstream3.ui.WatchType
|
import com.lagradost.cloudstream3.ui.WatchType
|
||||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO
|
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO
|
||||||
|
@ -685,7 +684,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
||||||
this.sendBroadcast(broadcastIntent)
|
this.sendBroadcast(broadcastIntent)
|
||||||
afterPluginsLoadedEvent -= ::onAllPluginsLoaded
|
afterPluginsLoadedEvent -= ::onAllPluginsLoaded
|
||||||
// run sync before app quits
|
// run sync before app quits
|
||||||
BackupApis.forEach { it.addToQueueNow() }
|
BackupApis.forEach { it.scheduleUpload() }
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1595,10 +1594,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
||||||
// showToast(this, currentFocus.toString(), Toast.LENGTH_LONG)
|
// showToast(this, currentFocus.toString(), Toast.LENGTH_LONG)
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
val local = LocalNetworkApi(this)
|
|
||||||
local.registerService()
|
|
||||||
local.discover()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun checkGithubConnectivity(): Boolean {
|
suspend fun checkGithubConnectivity(): Boolean {
|
||||||
|
|
|
@ -14,6 +14,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
||||||
val openSubtitlesApi = OpenSubtitlesApi(0)
|
val openSubtitlesApi = OpenSubtitlesApi(0)
|
||||||
val simklApi = SimklApi(0)
|
val simklApi = SimklApi(0)
|
||||||
val googleDriveApi = GoogleDriveApi(0)
|
val googleDriveApi = GoogleDriveApi(0)
|
||||||
|
val pcloudApi = PcloudApi(0)
|
||||||
val indexSubtitlesApi = IndexSubtitleApi()
|
val indexSubtitlesApi = IndexSubtitleApi()
|
||||||
val addic7ed = Addic7ed()
|
val addic7ed = Addic7ed()
|
||||||
val localListApi = LocalList()
|
val localListApi = LocalList()
|
||||||
|
@ -21,13 +22,18 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
||||||
// used to login via app intent
|
// used to login via app intent
|
||||||
val OAuth2Apis
|
val OAuth2Apis
|
||||||
get() = listOf<OAuth2API>(
|
get() = listOf<OAuth2API>(
|
||||||
malApi, aniListApi, simklApi, googleDriveApi
|
malApi, aniListApi, simklApi, googleDriveApi, pcloudApi
|
||||||
)
|
)
|
||||||
|
|
||||||
// this needs init with context and can be accessed in settings
|
// this needs init with context and can be accessed in settings
|
||||||
val accountManagers
|
val accountManagers
|
||||||
get() = listOf(
|
get() = listOf(
|
||||||
malApi, aniListApi, openSubtitlesApi, simklApi, googleDriveApi //, nginxApi
|
malApi,
|
||||||
|
aniListApi,
|
||||||
|
openSubtitlesApi,
|
||||||
|
simklApi,
|
||||||
|
googleDriveApi,
|
||||||
|
pcloudApi //, nginxApi
|
||||||
)
|
)
|
||||||
|
|
||||||
// used for active syncing
|
// used for active syncing
|
||||||
|
@ -39,12 +45,12 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
||||||
// used for active backup
|
// used for active backup
|
||||||
val BackupApis
|
val BackupApis
|
||||||
get() = listOf<BackupAPI<*>>(
|
get() = listOf<BackupAPI<*>>(
|
||||||
googleDriveApi
|
googleDriveApi, pcloudApi
|
||||||
)
|
)
|
||||||
|
|
||||||
val inAppAuths
|
val inAppAuths
|
||||||
get() = listOf(
|
get() = listOf(
|
||||||
openSubtitlesApi, googleDriveApi//, nginxApi
|
openSubtitlesApi, googleDriveApi, pcloudApi//, nginxApi
|
||||||
)
|
)
|
||||||
|
|
||||||
val subtitleProviders
|
val subtitleProviders
|
||||||
|
@ -94,7 +100,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
||||||
|
|
||||||
var accountIndex = defIndex
|
var accountIndex = defIndex
|
||||||
private var lastAccountIndex = defIndex
|
private var lastAccountIndex = defIndex
|
||||||
protected val accountId get() = "${idPrefix}_account_$accountIndex"
|
val accountId get() = "${idPrefix}_account_$accountIndex"
|
||||||
private val accountActiveKey get() = "${idPrefix}_active"
|
private val accountActiveKey get() = "${idPrefix}_active"
|
||||||
|
|
||||||
// int array of all accounts indexes
|
// int array of all accounts indexes
|
||||||
|
@ -132,6 +138,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
||||||
lastAccountIndex = accountIndex
|
lastAccountIndex = accountIndex
|
||||||
accountIndex = (accounts?.maxOrNull() ?: 0) + 1
|
accountIndex = (accounts?.maxOrNull() ?: 0) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun switchToOldAccount() {
|
protected fun switchToOldAccount() {
|
||||||
accountIndex = lastAccountIndex
|
accountIndex = lastAccountIndex
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,14 +4,19 @@ import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
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
|
||||||
import com.lagradost.cloudstream3.utils.BackupUtils.getBackup
|
import com.lagradost.cloudstream3.utils.BackupUtils.getBackup
|
||||||
import com.lagradost.cloudstream3.utils.BackupUtils.restore
|
import com.lagradost.cloudstream3.utils.BackupUtils.restore
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.DataStore
|
import com.lagradost.cloudstream3.utils.DataStore
|
||||||
import com.lagradost.cloudstream3.utils.Scheduler
|
import com.lagradost.cloudstream3.utils.Scheduler
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import org.skyscreamer.jsonassert.JSONCompare
|
import org.skyscreamer.jsonassert.JSONCompare
|
||||||
import org.skyscreamer.jsonassert.JSONCompareMode
|
import org.skyscreamer.jsonassert.JSONCompareMode
|
||||||
|
@ -19,7 +24,180 @@ import org.skyscreamer.jsonassert.JSONCompareResult
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
import kotlin.time.Duration.Companion.seconds
|
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(
|
data class JSONComparison(
|
||||||
val failed: Boolean,
|
val failed: Boolean,
|
||||||
val result: JSONCompareResult?
|
val result: JSONCompareResult?
|
||||||
|
@ -38,173 +216,141 @@ interface BackupAPI<LOGIN_DATA> {
|
||||||
val scheduler: Scheduler<PreferencesSchedulerData<*>>
|
val scheduler: Scheduler<PreferencesSchedulerData<*>>
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
/**
|
||||||
const val LOG_KEY = "BACKUP"
|
* Gets the user login info for uploading and downloading the backup.
|
||||||
const val SYNC_HISTORY_PREFIX = "_hs/"
|
* 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
|
* Additional check if the backup operation should be run.
|
||||||
// some kind of adaptive throttling which will increase decrease throttle time based
|
* Return false here to deny any backup work.
|
||||||
// on factors like: live devices, quota limits, etc
|
*/
|
||||||
val UPLOAD_THROTTLE = 10.seconds
|
suspend fun isReady(): Boolean = true
|
||||||
val DOWNLOAD_THROTTLE = 60.seconds
|
|
||||||
// add to queue may be called frequently
|
/**
|
||||||
private val ioScope = CoroutineScope(Dispatchers.IO)
|
* 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) {
|
fun SharedPreferences.logHistoryChanged(path: String, source: BackupUtils.RestoreSource) {
|
||||||
edit().putLong("${source.syncPrefix}$path", System.currentTimeMillis())
|
edit().putLong("${source.syncPrefix}$path", System.currentTimeMillis())
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
fun compareJson(old: String, new: String): JSONComparison {
|
||||||
* isActive is recommended to be overridden to verifiy if BackupApi is being used. if manager
|
var result: JSONCompareResult?
|
||||||
* 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)
|
|
||||||
|
|
||||||
/**
|
val executionTime = measureTimeMillis {
|
||||||
* Should upload data to API and call Context.createBackup(loginData: LOGIN_DATA)
|
result = try {
|
||||||
* @see Context.createBackup(loginData: LOGIN_DATA)
|
JSONCompare.compareJSON(old, new, JSONCompareMode.NON_EXTENSIBLE)
|
||||||
*/
|
} catch (e: Exception) {
|
||||||
fun uploadSyncData()
|
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)
|
return JSONComparison(failed, result)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val keysToUpdate = getKeysToUpdate(getBackup(), newData)
|
private fun getSyncKeys(data: BackupUtils.BackupFile) =
|
||||||
if (keysToUpdate.isEmpty()) {
|
data.syncMeta._Long.orEmpty().filter { it.key.startsWith(SYNC_HISTORY_PREFIX) }
|
||||||
Log.d(LOG_KEY, "remote data is up to date, sync not needed")
|
|
||||||
return
|
/**
|
||||||
|
* 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)
|
val changedKeys = newSync.filter {
|
||||||
restore(newData, keysToUpdate)
|
val localTimestamp = currentSync[it.key] ?: 0L
|
||||||
}
|
it.value > localTimestamp
|
||||||
|
}.keys
|
||||||
|
|
||||||
var willQueueSoon: Boolean?
|
val onlyLocalKeys = currentSync.keys.filter { !newSync.containsKey(it) }
|
||||||
var uploadJob: Job?
|
val missingKeys = getAllMissingKeys(currentData, newData)
|
||||||
fun shouldUpdate(changedKey: String, isSettings: Boolean): Boolean
|
|
||||||
fun addToQueue(changedKey: String, isSettings: Boolean) {
|
|
||||||
|
|
||||||
if (!shouldUpdate(changedKey, isSettings)) {
|
return (missingKeys + onlyLocalKeys + changedKeys).toSet()
|
||||||
willQueueSoon = false
|
|
||||||
Log.d(LOG_KEY, "upload not required, data is same")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addToQueueNow()
|
private fun getAllMissingKeys(
|
||||||
}
|
old: BackupUtils.BackupFile,
|
||||||
fun addToQueueNow() {
|
new: BackupUtils.BackupFile
|
||||||
if (uploadJob != null && uploadJob!!.isActive) {
|
): List<String> = BackupUtils.RestoreSource
|
||||||
Log.d(LOG_KEY, "upload is canceled, scheduling new")
|
.values()
|
||||||
uploadJob?.cancel()
|
.filter { it != BackupUtils.RestoreSource.SYNC }
|
||||||
}
|
.fold(mutableListOf()) { acc, source ->
|
||||||
|
acc.addAll(getMissingKeysPrefixed(source, old, new))
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
|
||||||
uploadJob = ioScope.launchSafe {
|
private fun getMissingKeysPrefixed(
|
||||||
willQueueSoon = false
|
restoreSource: BackupUtils.RestoreSource,
|
||||||
Log.d(LOG_KEY, "upload is running now")
|
old: BackupUtils.BackupFile,
|
||||||
uploadSyncData()
|
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 {
|
return listOf(
|
||||||
var result: JSONCompareResult?
|
*getMissing(oldSource._Bool, newSource._Bool),
|
||||||
|
*getMissing(oldSource._Long, newSource._Long),
|
||||||
val executionTime = measureTimeMillis {
|
*getMissing(oldSource._Float, newSource._Float),
|
||||||
result = try {
|
*getMissing(oldSource._Int, newSource._Int),
|
||||||
JSONCompare.compareJSON(old, new, JSONCompareMode.NON_EXTENSIBLE)
|
*getMissing(oldSource._String, newSource._String),
|
||||||
} catch (e: Exception) {
|
*getMissing(oldSource._StringSet, newSource._StringSet),
|
||||||
null
|
).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
|
package com.lagradost.cloudstream3.syncproviders
|
||||||
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
|
|
||||||
interface InAppAuthAPI : AuthAPI {
|
interface InAppAuthAPI : AuthAPI {
|
||||||
data class LoginData(
|
data class UserData(
|
||||||
val username: String? = null,
|
val username: String? = null,
|
||||||
val password: String? = null,
|
val password: String? = null,
|
||||||
val server: String? = null,
|
val server: String? = null,
|
||||||
|
@ -21,10 +19,10 @@ interface InAppAuthAPI : AuthAPI {
|
||||||
val storesPasswordInPlainText: Boolean
|
val storesPasswordInPlainText: Boolean
|
||||||
|
|
||||||
// return true if logged in successfully
|
// 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
|
// 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 {
|
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 val icon: Int? = null
|
||||||
|
|
||||||
override suspend fun login(data: InAppAuthAPI.LoginData): Boolean {
|
override suspend fun login(data: InAppAuthAPI.UserData): Boolean {
|
||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLatestLoginData(): InAppAuthAPI.LoginData? {
|
override fun getUserData(): InAppAuthAPI.UserData? {
|
||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.syncproviders
|
||||||
|
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.lagradost.cloudstream3.AcraApplication
|
|
||||||
|
|
||||||
interface InAppOAuth2API : OAuth2API {
|
interface InAppOAuth2API : OAuth2API {
|
||||||
data class LoginData(
|
data class LoginData(
|
||||||
|
@ -32,35 +31,3 @@ interface InAppOAuth2API : OAuth2API {
|
||||||
// used to fill the UI if you want to edit any data about your login info
|
// used to fill the UI if you want to edit any data about your login info
|
||||||
fun getLatestLoginData(): LoginData?
|
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.google.api.services.drive.model.File
|
||||||
import com.lagradost.cloudstream3.AcraApplication
|
import com.lagradost.cloudstream3.AcraApplication
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
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.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.CommonActivity
|
import com.lagradost.cloudstream3.CommonActivity
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.BackupAPI
|
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.InAppOAuth2API
|
||||||
import com.lagradost.cloudstream3.syncproviders.InAppOAuth2APIManager
|
import com.lagradost.cloudstream3.syncproviders.RemoteFile
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
|
||||||
import com.lagradost.cloudstream3.utils.BackupUtils.getBackup
|
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe
|
||||||
import com.lagradost.cloudstream3.utils.Scheduler
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
@ -43,10 +39,12 @@ import java.util.Date
|
||||||
*
|
*
|
||||||
* | State | Priority | Description
|
* | State | Priority | Description
|
||||||
* |---------:|:--------:|---------------------------------------------------------------------
|
* |---------:|:--------:|---------------------------------------------------------------------
|
||||||
* | Someday | 4 | Add button to manually trigger sync
|
|
||||||
* | Someday | 5 | Choose what should be synced and recheck `invalidKeys` in createBackupScheduler
|
* | 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 | 3 | Add option to use proper OAuth through Google Services One Tap
|
||||||
* | Someday | 5 | Encrypt data on Drive (low priority)
|
* | 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 | 1 | Racing conditions when multiple devices in use
|
||||||
* | Solved | 2 | Restoring backup should update view models
|
* | Solved | 2 | Restoring backup should update view models
|
||||||
* | Solved | 1 | Check if data was really changed when calling backupscheduler.work then
|
* | 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"
|
* | Solved | 3 | Move "https://chiff.github.io/cloudstream-sync/google-drive"
|
||||||
*/
|
*/
|
||||||
class GoogleDriveApi(index: Int) :
|
class GoogleDriveApi(index: Int) :
|
||||||
InAppOAuth2APIManager(index),
|
BackupAPI<InAppOAuth2API.LoginData>(index), InAppOAuth2API {
|
||||||
BackupAPI<InAppOAuth2API.LoginData> {
|
|
||||||
/////////////////////////////////////////
|
|
||||||
/////////////////////////////////////////
|
|
||||||
// Setup
|
|
||||||
override val key = "gdrive"
|
override val key = "gdrive"
|
||||||
override val redirectUrl = "oauth/google-drive"
|
override val redirectUrl = "oauth/google-drive"
|
||||||
|
|
||||||
|
@ -71,6 +65,8 @@ class GoogleDriveApi(index: Int) :
|
||||||
override val name = "Google Drive"
|
override val name = "Google Drive"
|
||||||
override val icon = R.drawable.ic_baseline_add_to_drive_24
|
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 requiresFilename = true
|
||||||
override val requiresSecret = true
|
override val requiresSecret = true
|
||||||
override val requiresClientId = true
|
override val requiresClientId = true
|
||||||
|
@ -79,17 +75,31 @@ class GoogleDriveApi(index: Int) :
|
||||||
"https://recloudstream.github.io/cloudstream-sync/google-drive"
|
"https://recloudstream.github.io/cloudstream-sync/google-drive"
|
||||||
override val infoUrl = "https://recloudstream.github.io/cloudstream-sync/google-drive/help.html"
|
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 tempAuthFlow: AuthorizationCodeFlow? = null
|
||||||
private var lastBackupJson: String? = null
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val GOOGLE_ACCOUNT_INFO_KEY = "google_account_info_key"
|
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
|
// OAuth2API implementation
|
||||||
|
@ -125,24 +135,17 @@ class GoogleDriveApi(index: Int) :
|
||||||
|
|
||||||
storeValue(K.TOKEN, googleTokenResponse)
|
storeValue(K.TOKEN, googleTokenResponse)
|
||||||
storeValue(K.IS_READY, true)
|
storeValue(K.IS_READY, true)
|
||||||
updateApiActiveState()
|
|
||||||
runDownloader(runNow = true, overwrite = true)
|
// First launch overwrites
|
||||||
|
scheduleDownload(runNow = true, overwrite = true)
|
||||||
|
|
||||||
tempAuthFlow = null
|
tempAuthFlow = null
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/////////////////////////////////////////
|
|
||||||
/////////////////////////////////////////
|
|
||||||
// InAppOAuth2APIManager implementation
|
|
||||||
override suspend fun initialize() {
|
override suspend fun initialize() {
|
||||||
updateApiActiveState()
|
|
||||||
if (isActive != true) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ioSafe {
|
ioSafe {
|
||||||
runDownloader(true)
|
scheduleDownload(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -179,7 +182,7 @@ class GoogleDriveApi(index: Int) :
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun loginInfo(): AuthAPI.LoginInfo? {
|
override fun loginInfo(): AuthAPI.LoginInfo? {
|
||||||
val driveService = getDriveService() ?: return null
|
val driveService = getLatestLoginData()?.let { getDriveService(it) } ?: return null
|
||||||
val userInfo = runBlocking {
|
val userInfo = runBlocking {
|
||||||
getUserInfo(driveService)
|
getUserInfo(driveService)
|
||||||
} ?: getBlankUser()
|
} ?: getBlankUser()
|
||||||
|
@ -210,7 +213,6 @@ class GoogleDriveApi(index: Int) :
|
||||||
this.tempAuthFlow = authFlow
|
this.tempAuthFlow = authFlow
|
||||||
|
|
||||||
try {
|
try {
|
||||||
updateApiActiveState()
|
|
||||||
registerAccount()
|
registerAccount()
|
||||||
|
|
||||||
val url = authFlow.newAuthorizationUrl().setRedirectUri(data.redirectUrl).build()
|
val url = authFlow.newAuthorizationUrl().setRedirectUri(data.redirectUrl).build()
|
||||||
|
@ -229,25 +231,68 @@ class GoogleDriveApi(index: Int) :
|
||||||
return getValue(K.LOGIN_DATA)
|
return getValue(K.LOGIN_DATA)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getLoginData(): InAppOAuth2API.LoginData? {
|
||||||
|
return getLatestLoginData()
|
||||||
|
}
|
||||||
|
|
||||||
/////////////////////////////////////////
|
/////////////////////////////////////////
|
||||||
/////////////////////////////////////////
|
/////////////////////////////////////////
|
||||||
// BackupAPI implementation
|
// BackupAPI implementation
|
||||||
override fun isActive(): Boolean {
|
override suspend fun isReady(): Boolean {
|
||||||
|
val loginData = getLatestLoginData()
|
||||||
return getValue<Boolean>(K.IS_READY) == true &&
|
return getValue<Boolean>(K.IS_READY) == true &&
|
||||||
loginInfo() != null &&
|
loginInfo() != null &&
|
||||||
getDriveService() != null &&
|
loginData != null &&
|
||||||
AcraApplication.context != null &&
|
getDriveService(loginData) != null &&
|
||||||
getLatestLoginData() != null
|
AcraApplication.context != null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun Context.createBackup(loginData: InAppOAuth2API.LoginData) {
|
override suspend fun getRemoteFile(
|
||||||
val drive = getDriveService() ?: return
|
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 fileName = loginData.fileName
|
||||||
val syncFileId = loginData.syncFileId
|
val syncFileId = loginData.syncFileId
|
||||||
val ioFile = java.io.File(AcraApplication.context?.cacheDir, fileName)
|
val ioFile = java.io.File(AcraApplication.context?.cacheDir, fileName)
|
||||||
lastBackupJson = getBackup().toJson()
|
ioFile.writeText(backupJson)
|
||||||
ioFile.writeText(lastBackupJson!!)
|
|
||||||
|
|
||||||
val fileMetadata = File()
|
val fileMetadata = File()
|
||||||
fileMetadata.name = fileName
|
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(
|
private fun getOrFindExistingSyncFileId(
|
||||||
drive: Drive,
|
drive: Drive,
|
||||||
loginData: InAppOAuth2API.LoginData
|
loginData: InAppOAuth2API.LoginData
|
||||||
|
@ -342,40 +353,8 @@ class GoogleDriveApi(index: Int) :
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun uploadSyncData() {
|
private fun getDriveService(loginData: InAppOAuth2API.LoginData): Drive? {
|
||||||
val canUpload = getValue<Boolean>(K.IS_READY)
|
val credential = getCredentialsFromStore(loginData) ?: return null
|
||||||
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
|
|
||||||
|
|
||||||
return Drive.Builder(
|
return Drive.Builder(
|
||||||
GAPI.HTTP_TRANSPORT,
|
GAPI.HTTP_TRANSPORT,
|
||||||
|
@ -386,41 +365,12 @@ class GoogleDriveApi(index: Int) :
|
||||||
.build()
|
.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) {
|
private fun getCredentialsFromStore(loginData: InAppOAuth2API.LoginData): Credential? {
|
||||||
if (runNow) {
|
|
||||||
continuousDownloader.workNow(overwrite)
|
|
||||||
} else {
|
|
||||||
continuousDownloader.work(overwrite)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getCredentialsFromStore(): Credential? {
|
|
||||||
val loginDate = getLatestLoginData()
|
|
||||||
val token = getValue<TokenResponse>(K.TOKEN)
|
val token = getValue<TokenResponse>(K.TOKEN)
|
||||||
|
|
||||||
val credential = if (loginDate != null && token != null) {
|
val credential = if (token != null) {
|
||||||
GAPI.getCredentials(token, loginDate)
|
GAPI.getCredentials(token, loginData)
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -437,6 +387,11 @@ class GoogleDriveApi(index: Int) :
|
||||||
return credential
|
return credential
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun logOut() {
|
||||||
|
K.values().forEach { clearValue(it) }
|
||||||
|
removeAccountKeys()
|
||||||
|
}
|
||||||
|
|
||||||
/////////////////////////////////////////
|
/////////////////////////////////////////
|
||||||
/////////////////////////////////////////
|
/////////////////////////////////////////
|
||||||
// Google API integration helper
|
// Google API integration helper
|
||||||
|
|
|
@ -90,9 +90,9 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLatestLoginData(): InAppAuthAPI.LoginData? {
|
override fun getUserData(): InAppAuthAPI.UserData? {
|
||||||
val current = getAuthKey() ?: return null
|
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
|
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 username = data.username ?: throw ErrorLoadingException("Requires Username")
|
||||||
val password = data.password ?: throw ErrorLoadingException("Requires Password")
|
val password = data.password ?: throw ErrorLoadingException("Requires Password")
|
||||||
switchToNewAccount()
|
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.googleDriveApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.pcloudApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
||||||
|
@ -140,7 +141,8 @@ class SettingsAccount : PreferenceFragmentCompat() {
|
||||||
R.string.anilist_key to aniListApi,
|
R.string.anilist_key to aniListApi,
|
||||||
R.string.simkl_key to simklApi,
|
R.string.simkl_key to simklApi,
|
||||||
R.string.opensubtitles_key to openSubtitlesApi,
|
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) {
|
for ((key, api) in syncApis) {
|
||||||
|
|
|
@ -9,6 +9,7 @@ import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
|
@ -17,11 +18,15 @@ import androidx.preference.Preference
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.google.android.material.appbar.MaterialToolbar
|
import com.google.android.material.appbar.MaterialToolbar
|
||||||
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.databinding.MainSettingsBinding
|
import com.lagradost.cloudstream3.databinding.MainSettingsBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
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.AccountManager.Companion.accountManagers
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||||
import com.lagradost.cloudstream3.ui.home.HomeFragment
|
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.fixPaddingStatusbar
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.setImage
|
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
|
// Default focus on TV
|
||||||
if (isTrueTv) {
|
if (isTrueTv) {
|
||||||
settingsGeneral.requestFocus()
|
settingsGeneral.requestFocus()
|
||||||
|
|
|
@ -27,7 +27,7 @@ class InAppAuthDialogBuilder(
|
||||||
override fun onLogin(dialog: AlertDialog): Unit = with(binding) {
|
override fun onLogin(dialog: AlertDialog): Unit = with(binding) {
|
||||||
// if (activity == null) throw IllegalStateException("Login should be called after validation")
|
// 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,
|
username = if (api.requiresUsername) loginUsernameInput.text?.toString() else null,
|
||||||
password = if (api.requiresPassword) loginPasswordInput.text?.toString() else null,
|
password = if (api.requiresPassword) loginPasswordInput.text?.toString() else null,
|
||||||
email = if (api.requiresEmail) loginEmailInput.text?.toString() else null,
|
email = if (api.requiresEmail) loginEmailInput.text?.toString() else null,
|
||||||
|
@ -36,7 +36,7 @@ class InAppAuthDialogBuilder(
|
||||||
|
|
||||||
ioSafe {
|
ioSafe {
|
||||||
val isSuccessful = try {
|
val isSuccessful = try {
|
||||||
api.login(loginData)
|
api.login(userData)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
false
|
false
|
||||||
|
@ -93,7 +93,7 @@ class InAppAuthDialogBuilder(
|
||||||
override fun handleStoresPasswordInPlainText(dialog: AlertDialog): Unit = with(binding) {
|
override fun handleStoresPasswordInPlainText(dialog: AlertDialog): Unit = with(binding) {
|
||||||
if (!api.storesPasswordInPlainText) return
|
if (!api.storesPasswordInPlainText) return
|
||||||
|
|
||||||
api.getLatestLoginData()?.let { data ->
|
api.getUserData()?.let { data ->
|
||||||
loginEmailInput.setText(data.email ?: "")
|
loginEmailInput.setText(data.email ?: "")
|
||||||
loginServerInput.setText(data.server ?: "")
|
loginServerInput.setText(data.server ?: "")
|
||||||
loginUsernameInput.setText(data.username ?: "")
|
loginUsernameInput.setText(data.username ?: "")
|
||||||
|
|
|
@ -3,8 +3,6 @@ package com.lagradost.cloudstream3.utils
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
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
|
||||||
import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL
|
import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL
|
||||||
import com.lagradost.cloudstream3.syncproviders.BackupAPI
|
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_CACHED_LIST
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_TOKEN_KEY
|
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_UNIXTIME_KEY
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_USER_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_CACHED_LIST
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_REFRESH_TOKEN_KEY
|
import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_REFRESH_TOKEN_KEY
|
||||||
import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_TOKEN_KEY
|
import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_TOKEN_KEY
|
||||||
|
@ -55,7 +54,7 @@ object BackupUtils {
|
||||||
DATA, SETTINGS, SYNC;
|
DATA, SETTINGS, SYNC;
|
||||||
|
|
||||||
val prefix = "$name/"
|
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_CACHED_LIST,
|
||||||
MAL_UNIXTIME_KEY,
|
MAL_UNIXTIME_KEY,
|
||||||
MAL_USER_KEY,
|
MAL_USER_KEY,
|
||||||
InAppOAuth2APIManager.K.TOKEN.value,
|
GoogleDriveApi.K.TOKEN.value,
|
||||||
InAppOAuth2APIManager.K.IS_READY.value,
|
GoogleDriveApi.K.IS_READY.value,
|
||||||
|
|
||||||
// The plugins themselves are not backed up
|
// The plugins themselves are not backed up
|
||||||
PLUGINS_KEY,
|
PLUGINS_KEY,
|
||||||
|
@ -81,6 +80,8 @@ object BackupUtils {
|
||||||
|
|
||||||
OPEN_SUBTITLES_USER_KEY,
|
OPEN_SUBTITLES_USER_KEY,
|
||||||
"nginx_user", // Nginx user key
|
"nginx_user", // Nginx user key
|
||||||
|
DOWNLOAD_HEADER_CACHE,
|
||||||
|
DOWNLOAD_EPISODE_CACHE
|
||||||
)
|
)
|
||||||
|
|
||||||
/** false if blacklisted key */
|
/** false if blacklisted key */
|
||||||
|
@ -328,7 +329,7 @@ object BackupUtils {
|
||||||
var prefixToRemove = prefixToMatch
|
var prefixToRemove = prefixToMatch
|
||||||
|
|
||||||
if (restoreSource == RestoreSource.SYNC) {
|
if (restoreSource == RestoreSource.SYNC) {
|
||||||
prefixToMatch = BackupAPI.SYNC_HISTORY_PREFIX
|
prefixToMatch = IBackupAPI.SYNC_HISTORY_PREFIX
|
||||||
prefixToRemove = ""
|
prefixToRemove = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,8 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.setKeyClass
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
import kotlin.reflect.KProperty
|
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"
|
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>
|
val key: String, val default: T //, private val klass: KClass<T>
|
||||||
) {
|
) {
|
||||||
private val klass: KClass<out T> = default::class
|
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
|
// simple cache to make it not get the key every time it is accessed, however this requires
|
||||||
// that ONLY this changes the key
|
// that ONLY this changes the key
|
||||||
private var cache: T? = null
|
private var cache: T? = null
|
||||||
|
@ -79,6 +81,7 @@ object DataStore {
|
||||||
fun getFolderName(folder: String, path: String): String {
|
fun getFolderName(folder: String, path: String): String {
|
||||||
return "${folder}/${path}"
|
return "${folder}/${path}"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T> Context.setKeyRaw(path: String, value: T, restoreSource: BackupUtils.RestoreSource) {
|
fun <T> Context.setKeyRaw(path: String, value: T, restoreSource: BackupUtils.RestoreSource) {
|
||||||
try {
|
try {
|
||||||
val editor = when (restoreSource) {
|
val editor = when (restoreSource) {
|
||||||
|
@ -100,7 +103,8 @@ object DataStore {
|
||||||
logError(e)
|
logError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun Context.removeKeyRaw(path: String, restoreSource: BackupUtils.RestoreSource) {
|
|
||||||
|
fun Context.removeKeyRaw(path: String, restoreSource: BackupUtils.RestoreSource) {
|
||||||
try {
|
try {
|
||||||
when (restoreSource) {
|
when (restoreSource) {
|
||||||
BackupUtils.RestoreSource.DATA -> getSharedPrefs().edit()
|
BackupUtils.RestoreSource.DATA -> getSharedPrefs().edit()
|
||||||
|
@ -143,15 +147,17 @@ object DataStore {
|
||||||
editor.remove(path)
|
editor.remove(path)
|
||||||
editor.apply()
|
editor.apply()
|
||||||
|
|
||||||
backupScheduler.work(
|
ioSafe {
|
||||||
BackupAPI.PreferencesSchedulerData(
|
backupScheduler.work(
|
||||||
getSyncPrefs(),
|
IBackupAPI.PreferencesSchedulerData(
|
||||||
path,
|
getSyncPrefs(),
|
||||||
oldValueExists,
|
path,
|
||||||
false,
|
oldValueExists,
|
||||||
BackupUtils.RestoreSource.DATA
|
false,
|
||||||
|
BackupUtils.RestoreSource.DATA
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
|
@ -176,15 +182,17 @@ object DataStore {
|
||||||
editor.putString(path, newValue)
|
editor.putString(path, newValue)
|
||||||
editor.apply()
|
editor.apply()
|
||||||
|
|
||||||
backupScheduler.work(
|
ioSafe {
|
||||||
BackupAPI.PreferencesSchedulerData(
|
backupScheduler.work(
|
||||||
getSyncPrefs(),
|
IBackupAPI.PreferencesSchedulerData(
|
||||||
path,
|
getSyncPrefs(),
|
||||||
oldValue,
|
path,
|
||||||
newValue,
|
oldValue,
|
||||||
BackupUtils.RestoreSource.DATA
|
newValue,
|
||||||
|
BackupUtils.RestoreSource.DATA
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,17 +6,20 @@ import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||||
import com.lagradost.cloudstream3.syncproviders.BackupAPI
|
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.home.HOME_BOOKMARK_VALUE_LIST
|
||||||
import com.lagradost.cloudstream3.ui.player.PLAYBACK_SPEED_KEY
|
import com.lagradost.cloudstream3.ui.player.PLAYBACK_SPEED_KEY
|
||||||
import com.lagradost.cloudstream3.ui.player.RESIZE_MODE_KEY
|
import com.lagradost.cloudstream3.ui.player.RESIZE_MODE_KEY
|
||||||
import com.lagradost.cloudstream3.utils.BackupUtils.nonTransferableKeys
|
import com.lagradost.cloudstream3.utils.BackupUtils.nonTransferableKeys
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
class Scheduler<INPUT>(
|
class Scheduler<INPUT>(
|
||||||
private val throttleTimeMs: Long,
|
private val throttleTimeMs: Long,
|
||||||
private val onWork: (INPUT?) -> Unit,
|
private val onWork: suspend (INPUT) -> Unit,
|
||||||
private val beforeWork: ((INPUT?) -> Unit)? = null,
|
private val beforeWork: (suspend (INPUT?) -> Unit)? = null,
|
||||||
private val canWork: ((INPUT?) -> Boolean)? = null
|
private val canWork: (suspend (INPUT) -> Boolean)? = null
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
var SCHEDULER_ID = 1
|
var SCHEDULER_ID = 1
|
||||||
|
@ -28,50 +31,61 @@ class Scheduler<INPUT>(
|
||||||
DOWNLOAD_HEADER_CACHE,
|
DOWNLOAD_HEADER_CACHE,
|
||||||
PLAYBACK_SPEED_KEY,
|
PLAYBACK_SPEED_KEY,
|
||||||
HOME_BOOKMARK_VALUE_LIST,
|
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,
|
BackupAPI.UPLOAD_THROTTLE.inWholeMilliseconds,
|
||||||
onWork = { input ->
|
onWork = { input ->
|
||||||
if (input == null) {
|
AccountManager.BackupApis.forEach { api ->
|
||||||
throw IllegalStateException()
|
api.scheduleUpload(
|
||||||
}
|
|
||||||
|
|
||||||
AccountManager.BackupApis.forEach {
|
|
||||||
it.addToQueue(
|
|
||||||
input.storeKey,
|
input.storeKey,
|
||||||
input.source == BackupUtils.RestoreSource.SETTINGS
|
input.source == BackupUtils.RestoreSource.SETTINGS
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeWork = {
|
beforeWork = { _ ->
|
||||||
AccountManager.BackupApis.filter {
|
AccountManager.BackupApis.filter { api ->
|
||||||
it.isActive == true
|
api.isReady()
|
||||||
}.forEach {
|
}.forEach {
|
||||||
it.willQueueSoon = true
|
it.willUploadSoon = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
canWork = { input ->
|
canWork = { input ->
|
||||||
if (input == null) {
|
val hasSomeActiveManagers = AccountManager.BackupApis.any { it.isReady() }
|
||||||
throw IllegalStateException()
|
|
||||||
}
|
|
||||||
|
|
||||||
val hasSomeActiveManagers = AccountManager.BackupApis.any { it.isActive == true }
|
|
||||||
if (!hasSomeActiveManagers) {
|
if (!hasSomeActiveManagers) {
|
||||||
return@Scheduler false
|
return@Scheduler false
|
||||||
}
|
}
|
||||||
|
|
||||||
val hasInvalidKey = invalidUploadTriggerKeys.contains(input.storeKey)
|
|
||||||
if (hasInvalidKey) {
|
|
||||||
return@Scheduler false
|
|
||||||
}
|
|
||||||
|
|
||||||
val valueDidNotChange = input.oldValue == input.newValue
|
val valueDidNotChange = input.oldValue == input.newValue
|
||||||
if (valueDidNotChange) {
|
if (valueDidNotChange) {
|
||||||
return@Scheduler false
|
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)
|
input.syncPrefs.logHistoryChanged(input.storeKey, input.source)
|
||||||
return@Scheduler true
|
return@Scheduler true
|
||||||
}
|
}
|
||||||
|
@ -83,27 +97,29 @@ class Scheduler<INPUT>(
|
||||||
fun SharedPreferences.attachBackupListener(
|
fun SharedPreferences.attachBackupListener(
|
||||||
source: BackupUtils.RestoreSource = BackupUtils.RestoreSource.SETTINGS,
|
source: BackupUtils.RestoreSource = BackupUtils.RestoreSource.SETTINGS,
|
||||||
syncPrefs: SharedPreferences
|
syncPrefs: SharedPreferences
|
||||||
): BackupAPI.SharedPreferencesWithListener {
|
): IBackupAPI.SharedPreferencesWithListener {
|
||||||
val scheduler = createBackupScheduler()
|
val scheduler = createBackupScheduler()
|
||||||
|
|
||||||
var lastValue = all
|
var lastValue = all
|
||||||
registerOnSharedPreferenceChangeListener { sharedPreferences, storeKey ->
|
registerOnSharedPreferenceChangeListener { sharedPreferences, storeKey ->
|
||||||
scheduler.work(
|
ioSafe {
|
||||||
BackupAPI.PreferencesSchedulerData(
|
scheduler.work(
|
||||||
syncPrefs,
|
IBackupAPI.PreferencesSchedulerData(
|
||||||
storeKey,
|
syncPrefs,
|
||||||
lastValue[storeKey],
|
storeKey,
|
||||||
sharedPreferences.all[storeKey],
|
lastValue[storeKey],
|
||||||
source
|
sharedPreferences.all[storeKey],
|
||||||
|
source
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
lastValue = sharedPreferences.all
|
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)
|
return attachBackupListener(BackupUtils.RestoreSource.SETTINGS, syncPrefs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -112,7 +128,7 @@ class Scheduler<INPUT>(
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private var runnable: Runnable? = null
|
private var runnable: Runnable? = null
|
||||||
|
|
||||||
fun work(input: INPUT? = null): Boolean {
|
suspend fun work(input: INPUT): Boolean {
|
||||||
if (canWork?.invoke(input) == false) {
|
if (canWork?.invoke(input) == false) {
|
||||||
// Log.d(LOG_KEY, "[$id] cannot schedule [${input}]")
|
// Log.d(LOG_KEY, "[$id] cannot schedule [${input}]")
|
||||||
return false
|
return false
|
||||||
|
@ -125,7 +141,7 @@ class Scheduler<INPUT>(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun workNow(input: INPUT? = null): Boolean {
|
suspend fun workNow(input: INPUT): Boolean {
|
||||||
if (canWork?.invoke(input) == false) {
|
if (canWork?.invoke(input) == false) {
|
||||||
Log.d(BackupAPI.LOG_KEY, "[$id] cannot run immediate [${input}]")
|
Log.d(BackupAPI.LOG_KEY, "[$id] cannot run immediate [${input}]")
|
||||||
return false
|
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()
|
stop()
|
||||||
|
|
||||||
runnable = Runnable {
|
runnable = Runnable {
|
||||||
Log.d(BackupAPI.LOG_KEY, "[$id] schedule success")
|
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
|
<TextView
|
||||||
android:id="@+id/settings_profile_text"
|
android:id="@+id/settings_profile_text"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center_vertical"
|
android:layout_gravity="center_vertical"
|
||||||
|
android:layout_weight="1"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:paddingStart="10dp"
|
android:paddingStart="10dp"
|
||||||
android:paddingEnd="10dp"
|
android:paddingEnd="10dp"
|
||||||
|
@ -51,6 +52,16 @@
|
||||||
android:textSize="18sp"
|
android:textSize="18sp"
|
||||||
android:textStyle="normal"
|
android:textStyle="normal"
|
||||||
tools:text="Hello world" />
|
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>
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|
|
@ -455,6 +455,7 @@
|
||||||
<string name="mal_key" translatable="false">mal_key</string>
|
<string name="mal_key" translatable="false">mal_key</string>
|
||||||
<string name="opensubtitles_key" translatable="false">opensubtitles_key</string>
|
<string name="opensubtitles_key" translatable="false">opensubtitles_key</string>
|
||||||
<string name="gdrive_key" translatable="false">gdrive_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="nginx_key" translatable="false">nginx_key</string>
|
||||||
<string name="example_password">password123</string>
|
<string name="example_password">password123</string>
|
||||||
<string name="example_username">MyCoolUsername</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_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="example_redirect_url" translatable="false">https://recloudstream.github.io/cloudstream-sync/google-drive</string>
|
||||||
<string name="info_button">Info</string>
|
<string name="info_button">Info</string>
|
||||||
|
<string name="sync_data">Sync data</string>
|
||||||
|
<string name="syncing_data">Syncing data</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -17,6 +17,10 @@
|
||||||
<Preference
|
<Preference
|
||||||
android:key="@string/gdrive_key"
|
android:key="@string/gdrive_key"
|
||||||
android:icon="@drawable/ic_baseline_add_to_drive_24" />
|
android:icon="@drawable/ic_baseline_add_to_drive_24" />
|
||||||
|
|
||||||
|
<Preference
|
||||||
|
android:key="@string/pcloud_key"
|
||||||
|
android:icon="@drawable/baseline_cloud_24" />
|
||||||
</PreferenceCategory>
|
</PreferenceCategory>
|
||||||
<!-- <Preference-->
|
<!-- <Preference-->
|
||||||
<!-- android:key="@string/nginx_key"-->
|
<!-- android:key="@string/nginx_key"-->
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue