mirror of
https://github.com/recloudstream/cloudstream.git
synced 2024-08-15 01:53:11 +00:00
Merge branch 'feature/remote-sync' into master
This commit is contained in:
commit
afaa06e592
37 changed files with 2218 additions and 251 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -14,3 +14,4 @@
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
local.properties
|
local.properties
|
||||||
|
/.idea
|
|
@ -4,6 +4,8 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.pm20.util.archivesName
|
||||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
|
@ -26,6 +28,15 @@ fun String.execute() = ByteArrayOutputStream().use { baot ->
|
||||||
else null
|
else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val localProperties = Properties()
|
||||||
|
try {
|
||||||
|
localProperties.load(FileInputStream(rootProject.file("local.properties")))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
localProperties.setProperty("debug.gdrive.clientId", "")
|
||||||
|
localProperties.setProperty("debug.gdrive.secret", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.isReturnDefaultValues = true
|
unitTests.isReturnDefaultValues = true
|
||||||
|
@ -112,6 +123,16 @@ android {
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
resValue(
|
||||||
|
"string",
|
||||||
|
"debug_gdrive_secret",
|
||||||
|
localProperties.getProperty("debug.gdrive.secret") ?: ""
|
||||||
|
)
|
||||||
|
resValue(
|
||||||
|
"string",
|
||||||
|
"debug_gdrive_clientId",
|
||||||
|
localProperties.getProperty("debug.gdrive.clientId") ?: ""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -229,6 +250,26 @@ dependencies {
|
||||||
^ Don't Bump Jackson above 2.13.1 , Crashes on Android TV's and FireSticks that have Min API
|
^ Don't Bump Jackson above 2.13.1 , Crashes on Android TV's and FireSticks that have Min API
|
||||||
Level 25 or Less. */
|
Level 25 or Less. */
|
||||||
|
|
||||||
|
// color palette for images -> colors
|
||||||
|
implementation("androidx.palette:palette-ktx:1.0.0")
|
||||||
|
|
||||||
|
implementation("org.skyscreamer:jsonassert:1.2.3")
|
||||||
|
implementation("com.google.api-client:google-api-client:2.0.0") {
|
||||||
|
exclude(
|
||||||
|
group = "org.apache.httpcomponents",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
implementation("com.google.oauth-client:google-oauth-client-jetty:1.34.1") {
|
||||||
|
exclude(
|
||||||
|
group = "org.apache.httpcomponents",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
implementation("com.google.apis:google-api-services-drive:v3-rev20220815-2.0.0") {
|
||||||
|
exclude(
|
||||||
|
group = "org.apache.httpcomponents",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Downloading & Networking
|
// Downloading & Networking
|
||||||
implementation("androidx.work:work-runtime:2.9.0")
|
implementation("androidx.work:work-runtime:2.9.0")
|
||||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
|
|
|
@ -87,6 +87,7 @@ import com.lagradost.cloudstream3.plugins.PluginManager.loadAllOnlinePlugins
|
||||||
import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin
|
import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin
|
||||||
import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver
|
import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver
|
||||||
import com.lagradost.cloudstream3.services.SubscriptionWorkManager
|
import com.lagradost.cloudstream3.services.SubscriptionWorkManager
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.BackupApis
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString
|
||||||
|
@ -314,6 +315,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
|
||||||
val mainPluginsLoadedEvent =
|
val mainPluginsLoadedEvent =
|
||||||
Event<Boolean>() // homepage api, used to speed up time to load for homepage
|
Event<Boolean>() // homepage api, used to speed up time to load for homepage
|
||||||
val afterRepositoryLoadedEvent = Event<Boolean>()
|
val afterRepositoryLoadedEvent = Event<Boolean>()
|
||||||
|
val afterBackupRestoreEvent = Event<Unit>()
|
||||||
|
|
||||||
// kinda shitty solution, but cant com main->home otherwise for popups
|
// kinda shitty solution, but cant com main->home otherwise for popups
|
||||||
val bookmarksUpdatedEvent = Event<Boolean>()
|
val bookmarksUpdatedEvent = Event<Boolean>()
|
||||||
|
@ -697,6 +699,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
|
||||||
broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java)
|
broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java)
|
||||||
this.sendBroadcast(broadcastIntent)
|
this.sendBroadcast(broadcastIntent)
|
||||||
afterPluginsLoadedEvent -= ::onAllPluginsLoaded
|
afterPluginsLoadedEvent -= ::onAllPluginsLoaded
|
||||||
|
// run sync before app quits
|
||||||
|
BackupApis.forEach { it.scheduleUpload() }
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.lagradost.cloudstream3.syncproviders
|
package com.lagradost.cloudstream3.syncproviders
|
||||||
|
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
|
@ -14,18 +15,26 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
||||||
val simklApi = SimklApi(0)
|
val simklApi = SimklApi(0)
|
||||||
val addic7ed = Addic7ed()
|
val addic7ed = Addic7ed()
|
||||||
val subDlApi = SubDlApi(0)
|
val subDlApi = SubDlApi(0)
|
||||||
|
val googleDriveApi = GoogleDriveApi(0)
|
||||||
|
val pcloudApi = PcloudApi(0)
|
||||||
val localListApi = LocalList()
|
val localListApi = LocalList()
|
||||||
|
|
||||||
// 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
|
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, subDlApi, simklApi //nginxApi
|
malApi,
|
||||||
|
aniListApi,
|
||||||
|
openSubtitlesApi,
|
||||||
|
subDlApi,
|
||||||
|
simklApi,
|
||||||
|
googleDriveApi,
|
||||||
|
pcloudApi //, nginxApi
|
||||||
)
|
)
|
||||||
|
|
||||||
// used for active syncing
|
// used for active syncing
|
||||||
|
@ -34,10 +43,18 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
||||||
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi)
|
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// used for active backup
|
||||||
|
val BackupApis
|
||||||
|
get() = listOf<SafeBackupAPI>(
|
||||||
|
googleDriveApi, pcloudApi
|
||||||
|
)
|
||||||
|
|
||||||
val inAppAuths
|
val inAppAuths
|
||||||
get() = listOf<InAppAuthAPIManager>(
|
get() = listOf(
|
||||||
openSubtitlesApi,
|
openSubtitlesApi,
|
||||||
subDlApi
|
subDlApi,
|
||||||
|
googleDriveApi,
|
||||||
|
pcloudApi
|
||||||
)//, nginxApi)
|
)//, nginxApi)
|
||||||
|
|
||||||
val subtitleProviders
|
val subtitleProviders
|
||||||
|
@ -87,12 +104,18 @@ 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
|
||||||
private val accountsKey get() = "${idPrefix}_accounts"
|
private val accountsKey get() = "${idPrefix}_accounts"
|
||||||
|
|
||||||
|
|
||||||
|
// runs on startup
|
||||||
|
@WorkerThread
|
||||||
|
open suspend fun initialize() {
|
||||||
|
}
|
||||||
|
|
||||||
protected fun removeAccountKeys() {
|
protected fun removeAccountKeys() {
|
||||||
removeKeys(accountId)
|
removeKeys(accountId)
|
||||||
val accounts = getAccounts()?.toMutableList() ?: mutableListOf()
|
val accounts = getAccounts()?.toMutableList() ?: mutableListOf()
|
||||||
|
@ -119,6 +142,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
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,411 @@
|
||||||
|
package com.lagradost.cloudstream3.syncproviders
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Log
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication
|
||||||
|
import com.lagradost.cloudstream3.mvvm.debugException
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
|
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
||||||
|
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||||
|
import com.lagradost.cloudstream3.utils.BackupUtils
|
||||||
|
import com.lagradost.cloudstream3.utils.BackupUtils.getBackup
|
||||||
|
import com.lagradost.cloudstream3.utils.BackupUtils.restore
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore
|
||||||
|
import com.lagradost.cloudstream3.utils.Scheduler
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import org.skyscreamer.jsonassert.JSONCompare
|
||||||
|
import org.skyscreamer.jsonassert.JSONCompareMode
|
||||||
|
import org.skyscreamer.jsonassert.JSONCompareResult
|
||||||
|
import kotlin.system.measureTimeMillis
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
interface RemoteFile {
|
||||||
|
class Error(val message: String? = null, val throwable: Throwable? = null) : RemoteFile
|
||||||
|
class NotFound : RemoteFile
|
||||||
|
class Success(val remoteData: String) : RemoteFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe wrapper for the backup api to be used outside the class without fear
|
||||||
|
* of causing crashes.
|
||||||
|
*/
|
||||||
|
interface SafeBackupAPI {
|
||||||
|
/**
|
||||||
|
* @return true if the service is ready for uploads and downloads.
|
||||||
|
* This includes a login check.
|
||||||
|
*/
|
||||||
|
suspend fun getIsReady(): Boolean
|
||||||
|
suspend fun scheduleDownload(runNow: Boolean = false)
|
||||||
|
fun getIsLoggedIn(): Boolean
|
||||||
|
fun scheduleUpload()
|
||||||
|
fun scheduleUpload(changedKey: String, isSettings: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warns the service that an upload is incoming. Used to prevent simultaneous download and upload.
|
||||||
|
*/
|
||||||
|
fun setIsUploadingSoon()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Easy interface to implement remote sync by only implementing the download and upload part of the service.
|
||||||
|
* @see BackupAPI for how the methods get used
|
||||||
|
*/
|
||||||
|
interface IBackupAPI<LOGIN_DATA> {
|
||||||
|
/**
|
||||||
|
* Gets the user login info for uploading and downloading the backup.
|
||||||
|
* If null no backup or download will be run.
|
||||||
|
*/
|
||||||
|
suspend fun getLoginData(): LOGIN_DATA?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional check if the backup operation should be run.
|
||||||
|
* Return false here to deny any backup work.
|
||||||
|
*/
|
||||||
|
suspend fun isReady(): Boolean = true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the backup file as a string from the remote storage.
|
||||||
|
* @see RemoteFile.Success
|
||||||
|
* @see RemoteFile.Error
|
||||||
|
* @see RemoteFile.NotFound
|
||||||
|
*/
|
||||||
|
suspend fun getRemoteFile(context: Context, loginData: LOGIN_DATA): RemoteFile
|
||||||
|
|
||||||
|
suspend fun uploadFile(
|
||||||
|
context: Context,
|
||||||
|
backupJson: String,
|
||||||
|
loginData: LOGIN_DATA
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper class for the IBackupAPI which implements a scheduler, logging and checks.
|
||||||
|
* This makes the individual backup service implementations easier and more lightweight.
|
||||||
|
*/
|
||||||
|
abstract class BackupAPI<LOGIN_DATA>(defIndex: Int) : IBackupAPI<LOGIN_DATA>,
|
||||||
|
AccountManager(defIndex), SafeBackupAPI {
|
||||||
|
data class JSONComparison(
|
||||||
|
val failed: Boolean,
|
||||||
|
val result: JSONCompareResult?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PreferencesSchedulerData<T>(
|
||||||
|
val syncPrefs: SharedPreferences,
|
||||||
|
val storeKey: String,
|
||||||
|
val oldValue: T,
|
||||||
|
val newValue: T,
|
||||||
|
val source: BackupUtils.RestoreSource
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SharedPreferencesWithListener(
|
||||||
|
val self: SharedPreferences,
|
||||||
|
val scheduler: Scheduler<PreferencesSchedulerData<*>>
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val LOG_KEY = "BACKUP"
|
||||||
|
|
||||||
|
// Can be called in high frequency (for now) because current implementation uses google
|
||||||
|
// cloud project per user so there is no way to hit quota. Later we should implement
|
||||||
|
// some kind of adaptive throttling which will increase decrease throttle time based
|
||||||
|
// on factors like: live devices, quota limits, etc
|
||||||
|
val UPLOAD_THROTTLE = 5.minutes
|
||||||
|
val DOWNLOAD_THROTTLE = 5.minutes
|
||||||
|
|
||||||
|
const val SYNC_HISTORY_PREFIX = "_hs/"
|
||||||
|
|
||||||
|
fun SharedPreferences.logHistoryChanged(
|
||||||
|
path: String,
|
||||||
|
source: BackupUtils.RestoreSource
|
||||||
|
) {
|
||||||
|
edit().putLong("${source.syncPrefix}$path", System.currentTimeMillis()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun compareJson(old: String, new: String): JSONComparison {
|
||||||
|
var result: JSONCompareResult?
|
||||||
|
|
||||||
|
val executionTime = measureTimeMillis {
|
||||||
|
result = try {
|
||||||
|
JSONCompare.compareJSON(old, new, JSONCompareMode.NON_EXTENSIBLE)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val failed = result?.failed() ?: true
|
||||||
|
Log.d(
|
||||||
|
LOG_KEY,
|
||||||
|
"JSON comparison took $executionTime ms, compareFailed=$failed, result=$result"
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONComparison(failed, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSyncKeys(data: BackupUtils.BackupFile) =
|
||||||
|
data.syncMeta._Long.orEmpty().filter { it.key.startsWith(SYNC_HISTORY_PREFIX) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges the backup data with the app data.
|
||||||
|
* @param overwrite if true it overwrites all data same as restoring from a backup.
|
||||||
|
* if false it only updates outdated keys. Should be true on first initialization.
|
||||||
|
*/
|
||||||
|
private fun mergeBackup(context: Context, incomingData: String, overwrite: Boolean) {
|
||||||
|
val newData = DataStore.mapper.readValue<BackupUtils.BackupFile>(incomingData)
|
||||||
|
if (overwrite) {
|
||||||
|
Log.d(LOG_KEY, "overwriting data")
|
||||||
|
context.restore(newData)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val keysToUpdate = getKeysToUpdate(getBackup(context), newData)
|
||||||
|
if (keysToUpdate.isEmpty()) {
|
||||||
|
Log.d(LOG_KEY, "remote data is up to date, sync not needed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(LOG_KEY, incomingData)
|
||||||
|
context.restore(newData, keysToUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getKeysToUpdate(
|
||||||
|
currentData: BackupUtils.BackupFile,
|
||||||
|
newData: BackupUtils.BackupFile
|
||||||
|
): Set<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 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var willUploadSoon: Boolean? = null
|
||||||
|
private var uploadJob: Job? = null
|
||||||
|
|
||||||
|
private fun shouldUploadBackup(): Boolean {
|
||||||
|
val ctx = AcraApplication.context ?: return false
|
||||||
|
|
||||||
|
val newBackup = getBackup(ctx).toJson()
|
||||||
|
return compareJson(lastBackupJson ?: "", newBackup).failed
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun scheduleUpload() {
|
||||||
|
normalSafeApiCall {
|
||||||
|
if (!shouldUploadBackup()) {
|
||||||
|
willUploadSoon = false
|
||||||
|
Log.d(LOG_KEY, "${this.name}: upload not required, data is same")
|
||||||
|
return@normalSafeApiCall
|
||||||
|
}
|
||||||
|
|
||||||
|
upload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// changedKey and isSettings is currently unused, might be useful for more efficient update checker.
|
||||||
|
override fun scheduleUpload(changedKey: String, isSettings: Boolean) {
|
||||||
|
normalSafeApiCall {
|
||||||
|
scheduleUpload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun upload() {
|
||||||
|
if (uploadJob != null && uploadJob!!.isActive) {
|
||||||
|
Log.d(LOG_KEY, "${this.name}: upload is canceled, scheduling new")
|
||||||
|
uploadJob?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = AcraApplication.context ?: return
|
||||||
|
uploadJob = ioSafe {
|
||||||
|
willUploadSoon = false
|
||||||
|
Log.d(LOG_KEY, "$name: uploadBackup is launched")
|
||||||
|
uploadBackup(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads the app data to the service if the api is ready and has login data.
|
||||||
|
* @see isReady
|
||||||
|
* @see getLoginData
|
||||||
|
*/
|
||||||
|
private suspend fun uploadBackup(context: Context) {
|
||||||
|
val isReady = isReady()
|
||||||
|
if (!isReady) {
|
||||||
|
Log.d(LOG_KEY, "${this.name}: uploadBackup is not ready yet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val loginData = getLoginData()
|
||||||
|
if (loginData == null) {
|
||||||
|
Log.d(LOG_KEY, "${this.name}: uploadBackup did not get loginData")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val backupFile = getBackup(context).toJson()
|
||||||
|
lastBackupJson = backupFile
|
||||||
|
Log.d(LOG_KEY, "${this.name}: uploadFile is now running")
|
||||||
|
uploadFile(context, backupFile, loginData)
|
||||||
|
Log.d(LOG_KEY, "${this.name}: uploadFile finished")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the remote backup and properly handle any errors, including uploading the backup
|
||||||
|
* if no remote file was found.
|
||||||
|
*/
|
||||||
|
private suspend fun getRemoteBackup(context: Context): String? {
|
||||||
|
if (!isReady()) {
|
||||||
|
Log.d(LOG_KEY, "${this.name}: getRemoteBackup is not ready yet")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val loginData = getLoginData()
|
||||||
|
if (loginData == null) {
|
||||||
|
Log.d(LOG_KEY, "${this.name}: getRemoteBackup did not get loginData")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (val remoteFile = getRemoteFile(context, loginData)) {
|
||||||
|
is RemoteFile.NotFound -> {
|
||||||
|
Log.d(LOG_KEY, "${this.name}: Remote file not found. Uploading file.")
|
||||||
|
uploadBackup(context)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
is RemoteFile.Success -> {
|
||||||
|
Log.d(LOG_KEY, "${this.name}: Remote file found.")
|
||||||
|
remoteFile.remoteData
|
||||||
|
}
|
||||||
|
|
||||||
|
is RemoteFile.Error -> {
|
||||||
|
Log.d(
|
||||||
|
LOG_KEY,
|
||||||
|
"${this.name}: getRemoteFile failed with message: ${remoteFile.message}."
|
||||||
|
)
|
||||||
|
remoteFile.throwable?.let { error -> logError(error) }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val message = "${this.name}: Unexpected remote file!"
|
||||||
|
debugException { message }
|
||||||
|
Log.d(LOG_KEY, message)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the remote backup and merges it with the local data.
|
||||||
|
* Also saves a cached to prevent unnecessary uploading.
|
||||||
|
* @see getRemoteBackup
|
||||||
|
* @see mergeBackup
|
||||||
|
*/
|
||||||
|
private suspend fun mergeRemoteBackup(context: Context, overwrite: Boolean) {
|
||||||
|
val remoteData = getRemoteBackup(context) ?: return
|
||||||
|
lastBackupJson = remoteData
|
||||||
|
mergeBackup(context, remoteData, overwrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ------ SafeBackupAPI wrappers ------
|
||||||
|
override suspend fun getIsReady(): Boolean {
|
||||||
|
return suspendSafeApiCall {
|
||||||
|
isReady()
|
||||||
|
} ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getIsLoggedIn(): Boolean {
|
||||||
|
return normalSafeApiCall { loginInfo() } != null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setIsUploadingSoon() {
|
||||||
|
willUploadSoon = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun scheduleDownload(runNow: Boolean) {
|
||||||
|
suspendSafeApiCall {
|
||||||
|
scheduleDownload(runNow, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
@ -35,11 +33,6 @@ abstract class InAppAuthAPIManager(defIndex: Int) : AccountManager(defIndex), In
|
||||||
override val storesPasswordInPlainText = true
|
override val storesPasswordInPlainText = true
|
||||||
override val requiresLogin = true
|
override val requiresLogin = true
|
||||||
|
|
||||||
// runs on startup
|
|
||||||
@WorkerThread
|
|
||||||
open suspend fun initialize() {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun logOut() {
|
override fun logOut() {
|
||||||
throw NotImplementedError()
|
throw NotImplementedError()
|
||||||
}
|
}
|
||||||
|
@ -52,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
package com.lagradost.cloudstream3.syncproviders
|
||||||
|
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
|
||||||
|
interface InAppOAuth2API : OAuth2API {
|
||||||
|
data class LoginData(
|
||||||
|
val secret: String,
|
||||||
|
val clientId: String,
|
||||||
|
val redirectUrl: String,
|
||||||
|
val fileNameInput: String,
|
||||||
|
var syncFileId: String?
|
||||||
|
) {
|
||||||
|
@JsonIgnore
|
||||||
|
val fileName = fileNameInput.replace(Regex("[^a-zA-Z0-9.\\-_]"), "") + ".json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is for displaying the UI
|
||||||
|
val requiresFilename: Boolean
|
||||||
|
val requiresSecret: Boolean
|
||||||
|
val requiresClientId: Boolean
|
||||||
|
|
||||||
|
val defaultFilenameValue: String
|
||||||
|
val defaultRedirectUrl: String
|
||||||
|
val infoUrl: String?
|
||||||
|
|
||||||
|
|
||||||
|
// should launch intent to acquire token
|
||||||
|
suspend fun getAuthorizationToken(activity: FragmentActivity, data: LoginData)
|
||||||
|
|
||||||
|
// used to fill the UI if you want to edit any data about your login info
|
||||||
|
fun getLatestLoginData(): LoginData?
|
||||||
|
}
|
|
@ -0,0 +1,429 @@
|
||||||
|
package com.lagradost.cloudstream3.syncproviders.providers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import com.google.api.client.auth.oauth2.AuthorizationCodeFlow
|
||||||
|
import com.google.api.client.auth.oauth2.Credential
|
||||||
|
import com.google.api.client.auth.oauth2.TokenResponse
|
||||||
|
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow
|
||||||
|
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport
|
||||||
|
import com.google.api.client.http.FileContent
|
||||||
|
import com.google.api.client.http.javanet.NetHttpTransport
|
||||||
|
import com.google.api.client.json.gson.GsonFactory
|
||||||
|
import com.google.api.client.util.store.MemoryDataStoreFactory
|
||||||
|
import com.google.api.services.drive.Drive
|
||||||
|
import com.google.api.services.drive.DriveScopes
|
||||||
|
import com.google.api.services.drive.model.File
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
|
import com.lagradost.cloudstream3.CommonActivity
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.BackupAPI
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.RemoteFile
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ## Improvements and ideas
|
||||||
|
*
|
||||||
|
* | State | Priority | Description
|
||||||
|
* |---------:|:--------:|---------------------------------------------------------------------
|
||||||
|
* | Someday | 5 | Choose what should be synced and recheck `invalidKeys` in createBackupScheduler
|
||||||
|
* | Someday | 3 | Add option to use proper OAuth through Google Services One Tap
|
||||||
|
* | Someday | 5 | Encrypt data on Drive (low priority)
|
||||||
|
* | Someday | 4 | Make local sync
|
||||||
|
* | Someday | 4 | Make sync button more interactive
|
||||||
|
* | Solved | 4 | Add button to manually trigger sync
|
||||||
|
* | Solved | 1 | Racing conditions when multiple devices in use
|
||||||
|
* | Solved | 2 | Restoring backup should update view models
|
||||||
|
* | Solved | 1 | Check if data was really changed when calling backupscheduler.work then
|
||||||
|
* | | | dont update sync meta if not needed
|
||||||
|
* | Solved | 4 | Implement backup before user quits application
|
||||||
|
* | Solved | 1 | Do not write sync meta when user is not syncing data
|
||||||
|
* | Solved | 1 | Fix sync/restore bugs
|
||||||
|
* | Solved | 1 | When scheduler has queued upload job (but is not working in backupApi
|
||||||
|
* | | | yet) we should postpone download and prioritize upload
|
||||||
|
* | Solved | 3 | Move "https://chiff.github.io/cloudstream-sync/google-drive"
|
||||||
|
*/
|
||||||
|
class GoogleDriveApi(index: Int) :
|
||||||
|
BackupAPI<InAppOAuth2API.LoginData>(index), InAppOAuth2API {
|
||||||
|
override val key = "gdrive"
|
||||||
|
override val redirectUrl = "oauth/google-drive"
|
||||||
|
|
||||||
|
override val idPrefix = "gdrive"
|
||||||
|
override val name = "Google Drive"
|
||||||
|
override val icon = R.drawable.ic_baseline_add_to_drive_24
|
||||||
|
|
||||||
|
override val requiresLogin = true
|
||||||
|
override val createAccountUrl = null
|
||||||
|
override val requiresFilename = true
|
||||||
|
override val requiresSecret = true
|
||||||
|
override val requiresClientId = true
|
||||||
|
override val defaultFilenameValue = "cloudstreamapp-sync-file"
|
||||||
|
override val defaultRedirectUrl =
|
||||||
|
"https://recloudstream.github.io/cloudstream-sync/google-drive"
|
||||||
|
override val infoUrl = "https://recloudstream.github.io/cloudstream-sync/google-drive/help.html"
|
||||||
|
|
||||||
|
private var tempAuthFlow: AuthorizationCodeFlow? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val GOOGLE_ACCOUNT_INFO_KEY = "google_account_info_key"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <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
|
||||||
|
override fun authenticate(activity: FragmentActivity?) {
|
||||||
|
// this was made for direct authentication for OAuth2
|
||||||
|
throw IllegalStateException("Authenticate should not be called")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun handleRedirect(url: String): Boolean {
|
||||||
|
val flow = tempAuthFlow
|
||||||
|
val data = getValue<InAppOAuth2API.LoginData>(K.LOGIN_DATA)
|
||||||
|
|
||||||
|
if (flow == null || data == null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = Uri.parse(url)
|
||||||
|
val code = uri.getQueryParameter("code")
|
||||||
|
|
||||||
|
val googleTokenResponse = try {
|
||||||
|
flow.newTokenRequest(code)
|
||||||
|
.setRedirectUri(data.redirectUrl)
|
||||||
|
.execute()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
switchToOldAccount()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
flow.createAndStoreCredential(
|
||||||
|
googleTokenResponse,
|
||||||
|
data.clientId
|
||||||
|
)
|
||||||
|
|
||||||
|
registerAccount()
|
||||||
|
storeValue(K.TOKEN, googleTokenResponse)
|
||||||
|
storeValue(K.IS_READY, true)
|
||||||
|
|
||||||
|
// First launch overwrites
|
||||||
|
scheduleDownload(runNow = true, overwrite = true)
|
||||||
|
|
||||||
|
tempAuthFlow = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun initialize() {
|
||||||
|
ioSafe {
|
||||||
|
scheduleDownload(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchUserInfo(driveService: Drive): GoogleUser? {
|
||||||
|
return ioWorkSafe {
|
||||||
|
val user = driveService.about()
|
||||||
|
.get()
|
||||||
|
.apply {
|
||||||
|
this.fields = "user"
|
||||||
|
}
|
||||||
|
.execute()
|
||||||
|
.user
|
||||||
|
GoogleUser(user.displayName, user.photoLink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getUserInfo(driveService: Drive): GoogleUser? {
|
||||||
|
return getKey(accountId, GOOGLE_ACCOUNT_INFO_KEY)
|
||||||
|
?: fetchUserInfo(driveService).also { user ->
|
||||||
|
setKey(accountId, GOOGLE_ACCOUNT_INFO_KEY, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class GoogleUser(
|
||||||
|
val displayName: String,
|
||||||
|
val photoLink: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getBlankUser(): GoogleUser {
|
||||||
|
return GoogleUser(
|
||||||
|
"google-account-$accountIndex",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loginInfo(): AuthAPI.LoginInfo? {
|
||||||
|
val driveService = getLatestLoginData()?.let { getDriveService(it) } ?: return null
|
||||||
|
val userInfo = runBlocking {
|
||||||
|
getUserInfo(driveService)
|
||||||
|
} ?: getBlankUser()
|
||||||
|
|
||||||
|
return AuthAPI.LoginInfo(
|
||||||
|
name = userInfo.displayName,
|
||||||
|
profilePicture = userInfo.photoLink,
|
||||||
|
accountIndex = accountIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////////////////////////////////
|
||||||
|
/////////////////////////////////////////
|
||||||
|
// InAppOAuth2API implementation
|
||||||
|
override suspend fun getAuthorizationToken(
|
||||||
|
activity: FragmentActivity,
|
||||||
|
data: InAppOAuth2API.LoginData
|
||||||
|
) {
|
||||||
|
val credential = loginInfo()
|
||||||
|
// Repeated attempts will not switch account because IS_READY is false
|
||||||
|
if (credential != null && getValue<Boolean>(K.IS_READY) != false) {
|
||||||
|
switchToNewAccount()
|
||||||
|
}
|
||||||
|
|
||||||
|
storeValue(K.IS_READY, false)
|
||||||
|
storeValue(K.LOGIN_DATA, data)
|
||||||
|
|
||||||
|
val authFlow = GAPI.createAuthFlow(data.clientId, data.secret)
|
||||||
|
this.tempAuthFlow = authFlow
|
||||||
|
|
||||||
|
try {
|
||||||
|
val url = authFlow.newAuthorizationUrl().setRedirectUri(data.redirectUrl).build()
|
||||||
|
openBrowser(url)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
CommonActivity.showToast(
|
||||||
|
activity,
|
||||||
|
activity.getString(R.string.authenticated_user_fail).format(name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLatestLoginData(): InAppOAuth2API.LoginData? {
|
||||||
|
return getValue(K.LOGIN_DATA)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getLoginData(): InAppOAuth2API.LoginData? {
|
||||||
|
return getLatestLoginData()
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////////////////////////////////
|
||||||
|
/////////////////////////////////////////
|
||||||
|
// BackupAPI implementation
|
||||||
|
override suspend fun isReady(): Boolean {
|
||||||
|
val loginData = getLatestLoginData()
|
||||||
|
return getValue<Boolean>(K.IS_READY) == true &&
|
||||||
|
loginInfo() != null &&
|
||||||
|
loginData != null &&
|
||||||
|
getDriveService(loginData) != null &&
|
||||||
|
AcraApplication.context != null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getRemoteFile(
|
||||||
|
context: Context,
|
||||||
|
loginData: InAppOAuth2API.LoginData
|
||||||
|
): RemoteFile {
|
||||||
|
val drive =
|
||||||
|
getDriveService(loginData) ?: return RemoteFile.Error("Cannot get drive service")
|
||||||
|
|
||||||
|
val existingFileId = getOrFindExistingSyncFileId(drive, loginData)
|
||||||
|
val existingFile = if (existingFileId != null) {
|
||||||
|
try {
|
||||||
|
drive.files().get(existingFileId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(LOG_KEY, "Could not find file for id $existingFileId", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingFile != null) {
|
||||||
|
try {
|
||||||
|
val inputStream: InputStream = existingFile.executeMediaAsInputStream()
|
||||||
|
val content: String = inputStream.bufferedReader().use { it.readText() }
|
||||||
|
Log.d(LOG_KEY, "downloadSyncData merging")
|
||||||
|
return RemoteFile.Success(content)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(LOG_KEY, "download failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if failed
|
||||||
|
Log.d(LOG_KEY, "downloadSyncData file not exists")
|
||||||
|
return RemoteFile.NotFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun uploadFile(
|
||||||
|
context: Context,
|
||||||
|
backupJson: String,
|
||||||
|
loginData: InAppOAuth2API.LoginData
|
||||||
|
) {
|
||||||
|
val drive = getDriveService(loginData) ?: return
|
||||||
|
|
||||||
|
val fileName = loginData.fileName
|
||||||
|
val syncFileId = loginData.syncFileId
|
||||||
|
val ioFile = java.io.File(AcraApplication.context?.cacheDir, fileName)
|
||||||
|
ioFile.writeText(backupJson)
|
||||||
|
|
||||||
|
val fileMetadata = File()
|
||||||
|
fileMetadata.name = fileName
|
||||||
|
fileMetadata.mimeType = "application/json"
|
||||||
|
val fileContent = FileContent("application/json", ioFile)
|
||||||
|
|
||||||
|
val fileId = getOrFindExistingSyncFileId(drive, loginData)
|
||||||
|
if (fileId != null) {
|
||||||
|
try {
|
||||||
|
val file = drive.files()
|
||||||
|
.update(fileId, fileMetadata, fileContent)
|
||||||
|
.setKeepRevisionForever(false)
|
||||||
|
.execute()
|
||||||
|
loginData.syncFileId = file.id
|
||||||
|
} catch (_: Exception) {
|
||||||
|
val file = drive.files().create(fileMetadata, fileContent).execute()
|
||||||
|
loginData.syncFileId = file.id
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val file = drive.files().create(fileMetadata, fileContent).execute()
|
||||||
|
loginData.syncFileId = file.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// in case we had to create new file
|
||||||
|
if (syncFileId != loginData.syncFileId) {
|
||||||
|
storeValue(K.LOGIN_DATA, loginData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOrFindExistingSyncFileId(
|
||||||
|
drive: Drive,
|
||||||
|
loginData: InAppOAuth2API.LoginData
|
||||||
|
): String? {
|
||||||
|
if (loginData.syncFileId != null) {
|
||||||
|
try {
|
||||||
|
val verified = drive.files().get(loginData.syncFileId).execute()
|
||||||
|
return verified.id
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val existingFileId: String? = drive
|
||||||
|
.files()
|
||||||
|
.list()
|
||||||
|
.setQ("name='${loginData.fileName}' and trashed=false")
|
||||||
|
.execute()
|
||||||
|
.files
|
||||||
|
?.getOrNull(0)
|
||||||
|
?.id
|
||||||
|
|
||||||
|
if (existingFileId != null) {
|
||||||
|
loginData.syncFileId = existingFileId
|
||||||
|
storeValue(K.LOGIN_DATA, loginData)
|
||||||
|
|
||||||
|
return existingFileId
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDriveService(loginData: InAppOAuth2API.LoginData): Drive? {
|
||||||
|
val credential = getCredentialsFromStore(loginData) ?: return null
|
||||||
|
|
||||||
|
return Drive.Builder(
|
||||||
|
GAPI.HTTP_TRANSPORT,
|
||||||
|
GAPI.JSON_FACTORY,
|
||||||
|
credential
|
||||||
|
)
|
||||||
|
.setApplicationName("cloudstreamapp-drive-sync")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun getCredentialsFromStore(loginData: InAppOAuth2API.LoginData): Credential? {
|
||||||
|
val token = getValue<TokenResponse>(K.TOKEN)
|
||||||
|
|
||||||
|
val credential = if (token != null) {
|
||||||
|
GAPI.getCredentials(token, loginData)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credential.expirationTimeMilliseconds < Date().time) {
|
||||||
|
val success = credential.refreshToken()
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
logOut()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return credential
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun logOut() {
|
||||||
|
K.values().forEach { clearValue(it) }
|
||||||
|
removeAccountKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////////////////////////////////
|
||||||
|
/////////////////////////////////////////
|
||||||
|
// Google API integration helper
|
||||||
|
object GAPI {
|
||||||
|
private const val DATA_STORE_ID = "gdrive_tokens"
|
||||||
|
private val USED_SCOPES = listOf(DriveScopes.DRIVE_FILE)
|
||||||
|
val HTTP_TRANSPORT: NetHttpTransport = GoogleNetHttpTransport.newTrustedTransport()
|
||||||
|
val JSON_FACTORY: GsonFactory = GsonFactory.getDefaultInstance()
|
||||||
|
|
||||||
|
fun createAuthFlow(clientId: String, clientSecret: String): GoogleAuthorizationCodeFlow =
|
||||||
|
GoogleAuthorizationCodeFlow.Builder(
|
||||||
|
HTTP_TRANSPORT,
|
||||||
|
JSON_FACTORY,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
USED_SCOPES
|
||||||
|
)
|
||||||
|
.setCredentialDataStore(MemoryDataStoreFactory().getDataStore(DATA_STORE_ID))
|
||||||
|
.setApprovalPrompt("force")
|
||||||
|
.setAccessType("offline")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun getCredentials(
|
||||||
|
tokenResponse: TokenResponse,
|
||||||
|
loginData: InAppOAuth2API.LoginData,
|
||||||
|
): Credential = createAuthFlow(
|
||||||
|
loginData.clientId,
|
||||||
|
loginData.secret
|
||||||
|
).loadCredential(loginData.clientId) ?: createAuthFlow(
|
||||||
|
loginData.clientId,
|
||||||
|
loginData.secret
|
||||||
|
).createAndStoreCredential(
|
||||||
|
tokenResponse,
|
||||||
|
loginData.clientId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,8 @@ import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
@ -22,7 +24,6 @@ import androidx.appcompat.widget.SearchView
|
||||||
import androidx.core.view.allViews
|
import androidx.core.view.allViews
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
@ -42,6 +43,7 @@ import com.lagradost.cloudstream3.mvvm.Resource
|
||||||
import com.lagradost.cloudstream3.mvvm.debugAssert
|
import com.lagradost.cloudstream3.mvvm.debugAssert
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.observe
|
import com.lagradost.cloudstream3.mvvm.observe
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.BackupAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||||
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
||||||
|
@ -60,6 +62,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
||||||
|
import org.checkerframework.framework.qual.Unused
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
@ -583,6 +586,21 @@ class LibraryFragment : Fragment() {
|
||||||
super.onConfigurationChanged(newConfig)
|
super.onConfigurationChanged(newConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
MainActivity.afterBackupRestoreEvent += ::onNewSyncData
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
MainActivity.afterBackupRestoreEvent -= ::onNewSyncData
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onNewSyncData(unused: Unit) {
|
||||||
|
Log.d(BackupAPI.LOG_KEY, "will reload pages")
|
||||||
|
libraryViewModel.reloadPages(true)
|
||||||
|
}
|
||||||
|
|
||||||
private val sortChangeClickListener = View.OnClickListener { view ->
|
private val sortChangeClickListener = View.OnClickListener { view ->
|
||||||
val methods = libraryViewModel.sortingMethods.map {
|
val methods = libraryViewModel.sortingMethods.map {
|
||||||
txt(it.stringRes).asString(view.context)
|
txt(it.stringRes).asString(view.context)
|
||||||
|
@ -598,10 +616,10 @@ class LibraryFragment : Fragment() {
|
||||||
libraryViewModel.sort(method)
|
libraryViewModel.sort(method)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class MenuSearchView(context: Context) : SearchView(context) {
|
class MenuSearchView(context: Context) : SearchView(context) {
|
||||||
override fun onActionViewCollapsed() {
|
override fun onActionViewCollapsed() {
|
||||||
super.onActionViewCollapsed()
|
super.onActionViewCollapsed()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -37,6 +37,7 @@ import com.lagradost.cloudstream3.mvvm.*
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
|
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
|
||||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders
|
||||||
|
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||||
import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage
|
import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage
|
||||||
import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding
|
import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding
|
||||||
import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType
|
import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType
|
||||||
|
@ -51,6 +52,7 @@ import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAu
|
||||||
import com.lagradost.cloudstream3.utils.*
|
import com.lagradost.cloudstream3.utils.*
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
|
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage
|
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.languages
|
import com.lagradost.cloudstream3.utils.SubtitleHelper.languages
|
||||||
|
@ -772,6 +774,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
||||||
|
|
||||||
binding.subtitlesClickSettings.setOnClickListener {
|
binding.subtitlesClickSettings.setOnClickListener {
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||||
|
.attachBackupListener(ctx.getSyncPrefs()).self
|
||||||
|
|
||||||
val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list)
|
val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list)
|
||||||
val prefValues = ctx.resources.getStringArray(R.array.subtitles_encoding_values)
|
val prefValues = ctx.resources.getStringArray(R.array.subtitles_encoding_values)
|
||||||
|
|
|
@ -2,12 +2,9 @@ package com.lagradost.cloudstream3.ui.settings
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.View.*
|
|
||||||
import android.view.inputmethod.EditorInfo
|
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.annotation.UiThread
|
import androidx.annotation.UiThread
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.core.view.isGone
|
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
|
@ -20,16 +17,19 @@ import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.databinding.AccountManagmentBinding
|
import com.lagradost.cloudstream3.databinding.AccountManagmentBinding
|
||||||
import com.lagradost.cloudstream3.databinding.AccountSwitchBinding
|
import com.lagradost.cloudstream3.databinding.AccountSwitchBinding
|
||||||
import com.lagradost.cloudstream3.databinding.AddAccountInputBinding
|
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.googleDriveApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.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.AccountManager.Companion.subDlApi
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subDlApi
|
||||||
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||||
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API
|
||||||
import com.lagradost.cloudstream3.syncproviders.OAuth2API
|
import com.lagradost.cloudstream3.syncproviders.OAuth2API
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
|
@ -40,6 +40,8 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.helpers.settings.account.InAppAuthDialogBuilder
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.helpers.settings.account.InAppOAuth2DialogBuilder
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.html
|
import com.lagradost.cloudstream3.utils.AppUtils.html
|
||||||
import com.lagradost.cloudstream3.utils.BackupUtils
|
import com.lagradost.cloudstream3.utils.BackupUtils
|
||||||
import com.lagradost.cloudstream3.utils.BiometricAuthenticator
|
import com.lagradost.cloudstream3.utils.BiometricAuthenticator
|
||||||
|
@ -130,129 +132,12 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricAuthenticator.Biome
|
||||||
}
|
}
|
||||||
|
|
||||||
@UiThread
|
@UiThread
|
||||||
fun addAccount(activity: FragmentActivity?, api: AccountManager) {
|
fun addAccount(activity: FragmentActivity, api: AccountManager) {
|
||||||
try {
|
try {
|
||||||
when (api) {
|
when (api) {
|
||||||
is OAuth2API -> {
|
is InAppOAuth2API -> InAppOAuth2DialogBuilder(api, activity).open()
|
||||||
api.authenticate(activity)
|
is OAuth2API -> api.authenticate(activity)
|
||||||
}
|
is InAppAuthAPI -> InAppAuthDialogBuilder(api, activity).open()
|
||||||
|
|
||||||
is InAppAuthAPI -> {
|
|
||||||
if (activity == null) return
|
|
||||||
val binding: AddAccountInputBinding =
|
|
||||||
AddAccountInputBinding.inflate(activity.layoutInflater, null, false)
|
|
||||||
val builder =
|
|
||||||
AlertDialog.Builder(activity, R.style.AlertDialogCustom)
|
|
||||||
.setView(binding.root)
|
|
||||||
val dialog = builder.show()
|
|
||||||
|
|
||||||
val visibilityMap = listOf(
|
|
||||||
binding.loginEmailInput to api.requiresEmail,
|
|
||||||
binding.loginPasswordInput to api.requiresPassword,
|
|
||||||
binding.loginServerInput to api.requiresServer,
|
|
||||||
binding.loginUsernameInput to api.requiresUsername
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isLayout(TV or EMULATOR)) {
|
|
||||||
visibilityMap.forEach { (input, isVisible) ->
|
|
||||||
input.isVisible = isVisible
|
|
||||||
|
|
||||||
// Band-aid for weird FireTV behavior causing crashes because keyboard covers the screen
|
|
||||||
input.setOnEditorActionListener { textView, actionId, _ ->
|
|
||||||
if (actionId == EditorInfo.IME_ACTION_NEXT) {
|
|
||||||
val view = textView.focusSearch(FOCUS_DOWN)
|
|
||||||
return@setOnEditorActionListener view?.requestFocus(
|
|
||||||
FOCUS_DOWN
|
|
||||||
) == true
|
|
||||||
}
|
|
||||||
return@setOnEditorActionListener true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
visibilityMap.forEach { (input, isVisible) ->
|
|
||||||
input.isVisible = isVisible
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.loginEmailInput.isVisible = api.requiresEmail
|
|
||||||
binding.loginPasswordInput.isVisible = api.requiresPassword
|
|
||||||
binding.loginServerInput.isVisible = api.requiresServer
|
|
||||||
binding.loginUsernameInput.isVisible = api.requiresUsername
|
|
||||||
binding.createAccount.isGone = api.createAccountUrl.isNullOrBlank()
|
|
||||||
binding.createAccount.setOnClickListener {
|
|
||||||
openBrowser(
|
|
||||||
api.createAccountUrl ?: return@setOnClickListener,
|
|
||||||
activity
|
|
||||||
)
|
|
||||||
dialog.dismissSafe()
|
|
||||||
}
|
|
||||||
|
|
||||||
val displayedItems = listOf(
|
|
||||||
binding.loginUsernameInput,
|
|
||||||
binding.loginEmailInput,
|
|
||||||
binding.loginServerInput,
|
|
||||||
binding.loginPasswordInput
|
|
||||||
).filter { it.isVisible }
|
|
||||||
|
|
||||||
displayedItems.foldRight(displayedItems.firstOrNull()) { item, previous ->
|
|
||||||
item.id.let { previous?.nextFocusDownId = it }
|
|
||||||
previous?.id?.let { item.nextFocusUpId = it }
|
|
||||||
item
|
|
||||||
}
|
|
||||||
|
|
||||||
displayedItems.firstOrNull()?.let {
|
|
||||||
binding.createAccount.nextFocusDownId = it.id
|
|
||||||
it.nextFocusUpId = binding.createAccount.id
|
|
||||||
}
|
|
||||||
binding.applyBtt.id.let {
|
|
||||||
displayedItems.lastOrNull()?.nextFocusDownId = it
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.text1.text = api.name
|
|
||||||
|
|
||||||
if (api.storesPasswordInPlainText) {
|
|
||||||
api.getLatestLoginData()?.let { data ->
|
|
||||||
binding.loginEmailInput.setText(data.email ?: "")
|
|
||||||
binding.loginServerInput.setText(data.server ?: "")
|
|
||||||
binding.loginUsernameInput.setText(data.username ?: "")
|
|
||||||
binding.loginPasswordInput.setText(data.password ?: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.applyBtt.setOnClickListener {
|
|
||||||
val loginData = InAppAuthAPI.LoginData(
|
|
||||||
username = if (api.requiresUsername) binding.loginUsernameInput.text?.toString() else null,
|
|
||||||
password = if (api.requiresPassword) binding.loginPasswordInput.text?.toString() else null,
|
|
||||||
email = if (api.requiresEmail) binding.loginEmailInput.text?.toString() else null,
|
|
||||||
server = if (api.requiresServer) binding.loginServerInput.text?.toString() else null,
|
|
||||||
)
|
|
||||||
ioSafe {
|
|
||||||
val isSuccessful = try {
|
|
||||||
api.login(loginData)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError(e)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
activity.runOnUiThread {
|
|
||||||
try {
|
|
||||||
showToast(
|
|
||||||
activity.getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail)
|
|
||||||
.format(
|
|
||||||
api.name
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError(e) // format might fail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dialog.dismissSafe(activity)
|
|
||||||
}
|
|
||||||
binding.cancelBtt.setOnClickListener {
|
|
||||||
dialog.dismissSafe(activity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
throw NotImplementedError("You are trying to add an account that has an unknown login method")
|
throw NotImplementedError("You are trying to add an account that has an unknown login method")
|
||||||
}
|
}
|
||||||
|
@ -324,6 +209,8 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricAuthenticator.Biome
|
||||||
R.string.simkl_key to simklApi,
|
R.string.simkl_key to simklApi,
|
||||||
R.string.opensubtitles_key to openSubtitlesApi,
|
R.string.opensubtitles_key to openSubtitlesApi,
|
||||||
R.string.subdl_key to subDlApi,
|
R.string.subdl_key to subDlApi,
|
||||||
|
R.string.gdrive_key to googleDriveApi,
|
||||||
|
R.string.pcloud_key to pcloudApi,
|
||||||
)
|
)
|
||||||
|
|
||||||
for ((key, api) in syncApis) {
|
for ((key, api) in syncApis) {
|
||||||
|
@ -331,11 +218,11 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricAuthenticator.Biome
|
||||||
title =
|
title =
|
||||||
getString(R.string.login_format).format(api.name, getString(R.string.account))
|
getString(R.string.login_format).format(api.name, getString(R.string.account))
|
||||||
setOnPreferenceClickListener {
|
setOnPreferenceClickListener {
|
||||||
val info = api.loginInfo()
|
val info = normalSafeApiCall { api.loginInfo() }
|
||||||
if (info != null) {
|
if (info != null) {
|
||||||
showLoginInfo(activity, api, info)
|
showLoginInfo(activity, api, info)
|
||||||
} else {
|
} else {
|
||||||
addAccount(activity, api)
|
activity?.let { activity -> addAccount(activity, api) }
|
||||||
}
|
}
|
||||||
return@setOnPreferenceClickListener true
|
return@setOnPreferenceClickListener true
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,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.updateLayoutParams
|
import androidx.core.view.updateLayoutParams
|
||||||
|
@ -15,10 +16,12 @@ import androidx.preference.PreferenceFragmentCompat
|
||||||
import com.google.android.material.appbar.AppBarLayout
|
import com.google.android.material.appbar.AppBarLayout
|
||||||
import com.google.android.material.appbar.MaterialToolbar
|
import com.google.android.material.appbar.MaterialToolbar
|
||||||
import com.lagradost.cloudstream3.BuildConfig
|
import com.lagradost.cloudstream3.BuildConfig
|
||||||
|
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
|
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.BackupApis
|
||||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers
|
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers
|
||||||
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.ui.result.txt
|
||||||
|
@ -27,6 +30,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper
|
import com.lagradost.cloudstream3.utils.UIHelper
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper
|
import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper
|
||||||
|
@ -106,7 +110,8 @@ class SettingsFragment : Fragment() {
|
||||||
}
|
}
|
||||||
fun Fragment?.setUpToolbar(title: String) {
|
fun Fragment?.setUpToolbar(title: String) {
|
||||||
if (this == null) return
|
if (this == null) return
|
||||||
val settingsToolbar = view?.findViewById<MaterialToolbar>(R.id.settings_toolbar) ?: return
|
val settingsToolbar =
|
||||||
|
view?.findViewById<MaterialToolbar>(R.id.settings_toolbar) ?: return
|
||||||
|
|
||||||
settingsToolbar.apply {
|
settingsToolbar.apply {
|
||||||
setTitle(title)
|
setTitle(title)
|
||||||
|
@ -122,7 +127,8 @@ class SettingsFragment : Fragment() {
|
||||||
|
|
||||||
fun Fragment?.setUpToolbar(@StringRes title: Int) {
|
fun Fragment?.setUpToolbar(@StringRes title: Int) {
|
||||||
if (this == null) return
|
if (this == null) return
|
||||||
val settingsToolbar = view?.findViewById<MaterialToolbar>(R.id.settings_toolbar) ?: return
|
val settingsToolbar =
|
||||||
|
view?.findViewById<MaterialToolbar>(R.id.settings_toolbar) ?: return
|
||||||
|
|
||||||
settingsToolbar.apply {
|
settingsToolbar.apply {
|
||||||
setTitle(title)
|
setTitle(title)
|
||||||
|
@ -231,6 +237,22 @@ class SettingsFragment : Fragment() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only show the button if the api does not require login, requires login, but the user is logged in
|
||||||
|
forceSyncDataBtt.isVisible = BackupApis.any { api ->
|
||||||
|
api.getIsLoggedIn()
|
||||||
|
}
|
||||||
|
|
||||||
|
forceSyncDataBtt.setOnClickListener {
|
||||||
|
BackupApis.forEach { api ->
|
||||||
|
api.scheduleUpload()
|
||||||
|
}
|
||||||
|
showToast(activity, txt(R.string.syncing_data), Toast.LENGTH_SHORT)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
forceSyncDataBtt.tooltipText =
|
||||||
|
txt(R.string.sync_data).asString(forceSyncDataBtt.context)
|
||||||
|
}
|
||||||
|
|
||||||
// Default focus on TV
|
// Default focus on TV
|
||||||
if (isLayout(TV)) {
|
if (isLayout(TV)) {
|
||||||
settingsGeneral.requestFocus()
|
settingsGeneral.requestFocus()
|
||||||
|
|
|
@ -26,6 +26,7 @@ import com.lagradost.cloudstream3.databinding.AddSiteInputBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
import com.lagradost.cloudstream3.network.initClient
|
import com.lagradost.cloudstream3.network.initClient
|
||||||
|
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||||
import com.lagradost.cloudstream3.ui.EasterEggMonke
|
import com.lagradost.cloudstream3.ui.EasterEggMonke
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
|
@ -39,6 +40,7 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setTool
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
||||||
import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.isAppRestricted
|
import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.isAppRestricted
|
||||||
import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.showBatteryOptimizationDialog
|
import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.showBatteryOptimizationDialog
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
|
||||||
|
@ -166,13 +168,19 @@ class SettingsGeneral : PreferenceFragmentCompat() {
|
||||||
// Stores the real URI using download_path_key
|
// Stores the real URI using download_path_key
|
||||||
// Important that the URI is stored instead of filepath due to permissions.
|
// Important that the URI is stored instead of filepath due to permissions.
|
||||||
PreferenceManager.getDefaultSharedPreferences(context)
|
PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
.edit().putString(getString(R.string.download_path_key), uri.toString()).apply()
|
.attachBackupListener(context.getSyncPrefs()).self
|
||||||
|
.edit()
|
||||||
|
.putString(getString(R.string.download_path_key), uri.toString())
|
||||||
|
.apply()
|
||||||
|
|
||||||
// From URI -> File path
|
// From URI -> File path
|
||||||
// File path here is purely for cosmetic purposes in settings
|
// File path here is purely for cosmetic purposes in settings
|
||||||
(filePath ?: uri.toString()).let {
|
(filePath ?: uri.toString()).let {
|
||||||
PreferenceManager.getDefaultSharedPreferences(context)
|
PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
.edit().putString(getString(R.string.download_path_pref), it).apply()
|
.attachBackupListener(context.getSyncPrefs()).self
|
||||||
|
.edit()
|
||||||
|
.putString(getString(R.string.download_path_pref), it)
|
||||||
|
.apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -180,6 +188,7 @@ class SettingsGeneral : PreferenceFragmentCompat() {
|
||||||
hideKeyboard()
|
hideKeyboard()
|
||||||
setPreferencesFromResource(R.xml.settings_general, rootKey)
|
setPreferencesFromResource(R.xml.settings_general, rootKey)
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||||
|
.attachBackupListener(requireContext().getSyncPrefs()).self
|
||||||
|
|
||||||
fun getCurrent(): MutableList<CustomSite> {
|
fun getCurrent(): MutableList<CustomSite> {
|
||||||
return getKey<Array<CustomSite>>(USER_PROVIDER_API)?.toMutableList()
|
return getKey<Array<CustomSite>>(USER_PROVIDER_API)?.toMutableList()
|
||||||
|
|
|
@ -7,6 +7,7 @@ import androidx.preference.PreferenceFragmentCompat
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
|
@ -20,6 +21,7 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setTool
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
||||||
import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment
|
import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment
|
||||||
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
|
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||||
import com.lagradost.cloudstream3.utils.Qualities
|
import com.lagradost.cloudstream3.utils.Qualities
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
||||||
|
@ -36,6 +38,7 @@ class SettingsPlayer : PreferenceFragmentCompat() {
|
||||||
hideKeyboard()
|
hideKeyboard()
|
||||||
setPreferencesFromResource(R.xml.settings_player, rootKey)
|
setPreferencesFromResource(R.xml.settings_player, rootKey)
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||||
|
.attachBackupListener(requireContext().getSyncPrefs()).self
|
||||||
|
|
||||||
//Hide specific prefs on TV/EMULATOR
|
//Hide specific prefs on TV/EMULATOR
|
||||||
hidePrefs(
|
hidePrefs(
|
||||||
|
|
|
@ -6,20 +6,26 @@ import androidx.navigation.NavOptions
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.lagradost.cloudstream3.*
|
import com.lagradost.cloudstream3.APIHolder
|
||||||
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
|
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
|
||||||
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
|
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||||
|
import com.lagradost.cloudstream3.AllLanguagesName
|
||||||
|
import com.lagradost.cloudstream3.DubStatus
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.TvType
|
||||||
|
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||||
import com.lagradost.cloudstream3.ui.APIRepository
|
import com.lagradost.cloudstream3.ui.APIRepository
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
|
||||||
|
|
||||||
class SettingsProviders : PreferenceFragmentCompat() {
|
class SettingsProviders : PreferenceFragmentCompat() {
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
@ -33,6 +39,7 @@ class SettingsProviders : PreferenceFragmentCompat() {
|
||||||
hideKeyboard()
|
hideKeyboard()
|
||||||
setPreferencesFromResource(R.xml.settings_providers, rootKey)
|
setPreferencesFromResource(R.xml.settings_providers, rootKey)
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||||
|
.attachBackupListener(requireContext().getSyncPrefs()).self
|
||||||
|
|
||||||
getPref(R.string.display_sub_key)?.setOnPreferenceClickListener {
|
getPref(R.string.display_sub_key)?.setOnPreferenceClickListener {
|
||||||
activity?.getApiDubstatusSettings()?.let { current ->
|
activity?.getApiDubstatusSettings()?.let { current ->
|
||||||
|
|
|
@ -8,12 +8,14 @@ import androidx.preference.PreferenceManager
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.SearchQuality
|
import com.lagradost.cloudstream3.SearchQuality
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||||
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
|
import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
|
||||||
|
@ -31,6 +33,7 @@ class SettingsUI : PreferenceFragmentCompat() {
|
||||||
hideKeyboard()
|
hideKeyboard()
|
||||||
setPreferencesFromResource(R.xml.settings_ui, rootKey)
|
setPreferencesFromResource(R.xml.settings_ui, rootKey)
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||||
|
.attachBackupListener(requireContext().getSyncPrefs()).self
|
||||||
|
|
||||||
getPref(R.string.poster_ui_key)?.setOnPreferenceClickListener {
|
getPref(R.string.poster_ui_key)?.setOnPreferenceClickListener {
|
||||||
val prefNames = resources.getStringArray(R.array.poster_ui_options)
|
val prefNames = resources.getStringArray(R.array.poster_ui_options)
|
||||||
|
|
|
@ -15,6 +15,7 @@ import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.databinding.LogcatBinding
|
import com.lagradost.cloudstream3.databinding.LogcatBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.network.initClient
|
import com.lagradost.cloudstream3.network.initClient
|
||||||
|
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||||
import com.lagradost.cloudstream3.services.BackupWorkManager
|
import com.lagradost.cloudstream3.services.BackupWorkManager
|
||||||
import com.lagradost.cloudstream3.ui.result.txt
|
import com.lagradost.cloudstream3.ui.result.txt
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref
|
||||||
|
@ -24,6 +25,7 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpTo
|
||||||
import com.lagradost.cloudstream3.utils.BackupUtils
|
import com.lagradost.cloudstream3.utils.BackupUtils
|
||||||
import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt
|
import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||||
import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
|
import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
||||||
|
@ -157,6 +159,7 @@ class SettingsUpdates : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
getPref(R.string.apk_installer_key)?.setOnPreferenceClickListener {
|
getPref(R.string.apk_installer_key)?.setOnPreferenceClickListener {
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context)
|
||||||
|
.attachBackupListener(it.context.getSyncPrefs()).self
|
||||||
|
|
||||||
val prefNames = resources.getStringArray(R.array.apk_installer_pref)
|
val prefNames = resources.getStringArray(R.array.apk_installer_pref)
|
||||||
val prefValues = resources.getIntArray(R.array.apk_installer_values)
|
val prefValues = resources.getIntArray(R.array.apk_installer_values)
|
||||||
|
|
|
@ -0,0 +1,137 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.settings.helpers.settings.account
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||||
|
|
||||||
|
abstract class DialogBuilder<T : ViewBinding>(
|
||||||
|
private val api: AuthAPI,
|
||||||
|
private val activity: FragmentActivity?,
|
||||||
|
private val themeResId: Int,
|
||||||
|
val binding: T,
|
||||||
|
) {
|
||||||
|
class CommonDialogItems(
|
||||||
|
private val dialog: AlertDialog,
|
||||||
|
private val title: TextView,
|
||||||
|
private val btnApply: MaterialButton,
|
||||||
|
private val btnCancel: MaterialButton,
|
||||||
|
private val btnAccCreate: MaterialButton?,
|
||||||
|
private val btnInfo: MaterialButton?
|
||||||
|
) {
|
||||||
|
fun getTitle() = dialog.getCommonItem(title)!!
|
||||||
|
fun getBtnApply() = dialog.getCommonItem(btnApply)!!
|
||||||
|
fun getBtnCancel() = dialog.getCommonItem(btnCancel)!!
|
||||||
|
fun getBtnAccCreate() = dialog.getCommonItem(btnAccCreate)
|
||||||
|
fun getBtnInfo() = dialog.getCommonItem(btnInfo)
|
||||||
|
|
||||||
|
private fun <T : View> AlertDialog.getCommonItem(view: T?): T? {
|
||||||
|
return findViewById(view?.id ?: return null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
abstract fun getCommonItems(dialog: AlertDialog): CommonDialogItems
|
||||||
|
abstract fun getVisibilityMap(dialog: AlertDialog): Map<View, Boolean>
|
||||||
|
abstract fun setupItems(dialog: AlertDialog)
|
||||||
|
|
||||||
|
|
||||||
|
open fun handleStoresPasswordInPlainText(dialog: AlertDialog) {}
|
||||||
|
open fun onDismiss(dialog: AlertDialog) {
|
||||||
|
dialog.dismissSafe(activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun onLogin(dialog: AlertDialog) {
|
||||||
|
dialog.dismissSafe(activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun open(): AlertDialog? {
|
||||||
|
if (activity == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialogBuilder = AlertDialog.Builder(activity, themeResId).setView(binding.root)
|
||||||
|
val dialog = dialogBuilder.show()
|
||||||
|
|
||||||
|
setup(dialog)
|
||||||
|
handleStoresPasswordInPlainText(dialog)
|
||||||
|
|
||||||
|
val commonItems = getCommonItems(dialog)
|
||||||
|
commonItems.getTitle().text = api.name
|
||||||
|
commonItems.getBtnApply().setOnClickListener { onLogin(dialog) }
|
||||||
|
commonItems.getBtnCancel().setOnClickListener { onDismiss(dialog) }
|
||||||
|
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected fun setup(dialog: AlertDialog) {
|
||||||
|
setItemVisibility(dialog)
|
||||||
|
setupItems(dialog)
|
||||||
|
linkItems(dialog)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setItemVisibility(dialog: AlertDialog) {
|
||||||
|
val visibilityMap = getVisibilityMap(dialog)
|
||||||
|
|
||||||
|
if (SettingsFragment.isTvSettings()) {
|
||||||
|
visibilityMap.forEach { (input, isVisible) ->
|
||||||
|
input.isVisible = isVisible
|
||||||
|
|
||||||
|
if (input !is TextView) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
// Band-aid for weird FireTV behavior causing crashes because keyboard covers the screen
|
||||||
|
input.setOnEditorActionListener { textView, actionId, _ ->
|
||||||
|
if (actionId == EditorInfo.IME_ACTION_NEXT) {
|
||||||
|
val view = textView.focusSearch(View.FOCUS_DOWN)
|
||||||
|
return@setOnEditorActionListener view?.requestFocus(
|
||||||
|
View.FOCUS_DOWN
|
||||||
|
) == true
|
||||||
|
}
|
||||||
|
return@setOnEditorActionListener true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
visibilityMap.forEach { (input, isVisible) ->
|
||||||
|
input.isVisible = isVisible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun linkItems(dialog: AlertDialog) = with(dialog) {
|
||||||
|
val displayedItems = getVisibilityMap(dialog).keys.filter { it.isVisible }
|
||||||
|
|
||||||
|
displayedItems.foldRight(displayedItems.firstOrNull()) { item, previous ->
|
||||||
|
item.id.let { previous?.nextFocusDownId = it }
|
||||||
|
previous?.id?.let { item.nextFocusUpId = it }
|
||||||
|
item
|
||||||
|
}
|
||||||
|
|
||||||
|
displayedItems.firstOrNull()?.let {
|
||||||
|
val createAccount = getCommonItems(dialog).getBtnAccCreate() ?: return@let
|
||||||
|
createAccount.nextFocusDownId = it.id
|
||||||
|
it.nextFocusUpId = createAccount.id
|
||||||
|
}
|
||||||
|
|
||||||
|
displayedItems.firstOrNull()?.let {
|
||||||
|
val infoButton = getCommonItems(dialog).getBtnInfo() ?: return@let
|
||||||
|
infoButton.nextFocusDownId = it.id
|
||||||
|
it.nextFocusUpId = infoButton.id
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommonItems(dialog).getBtnApply().id.let {
|
||||||
|
displayedItems.lastOrNull()?.nextFocusDownId = it
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,103 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.settings.helpers.settings.account
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication
|
||||||
|
import com.lagradost.cloudstream3.CommonActivity
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.databinding.AddAccountInputBinding
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||||
|
|
||||||
|
class InAppAuthDialogBuilder(
|
||||||
|
private val api: InAppAuthAPI,
|
||||||
|
private val activity: FragmentActivity,
|
||||||
|
) : DialogBuilder<AddAccountInputBinding>(
|
||||||
|
api,
|
||||||
|
activity,
|
||||||
|
R.style.AlertDialogCustom,
|
||||||
|
AddAccountInputBinding.inflate(activity.layoutInflater),
|
||||||
|
) {
|
||||||
|
|
||||||
|
override fun onLogin(dialog: AlertDialog): Unit = with(binding) {
|
||||||
|
// if (activity == null) throw IllegalStateException("Login should be called after validation")
|
||||||
|
|
||||||
|
val userData = InAppAuthAPI.UserData(
|
||||||
|
username = if (api.requiresUsername) loginUsernameInput.text?.toString() else null,
|
||||||
|
password = if (api.requiresPassword) loginPasswordInput.text?.toString() else null,
|
||||||
|
email = if (api.requiresEmail) loginEmailInput.text?.toString() else null,
|
||||||
|
server = if (api.requiresServer) loginServerInput.text?.toString() else null,
|
||||||
|
)
|
||||||
|
|
||||||
|
ioSafe {
|
||||||
|
val isSuccessful = try {
|
||||||
|
api.login(userData)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (isSuccessful) {
|
||||||
|
dialog.dismissSafe()
|
||||||
|
}
|
||||||
|
activity.runOnUiThread {
|
||||||
|
try {
|
||||||
|
CommonActivity.showToast(
|
||||||
|
activity,
|
||||||
|
activity.getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail)
|
||||||
|
.format(
|
||||||
|
api.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e) // format might fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCommonItems(dialog: AlertDialog) = with(binding) {
|
||||||
|
CommonDialogItems(dialog, text1, applyBtt, cancelBtt, createAccount,null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getVisibilityMap(dialog: AlertDialog): Map<View, Boolean> = with(binding) {
|
||||||
|
mapOf(
|
||||||
|
loginEmailInput to api.requiresEmail,
|
||||||
|
loginPasswordInput to api.requiresPassword,
|
||||||
|
loginServerInput to api.requiresServer,
|
||||||
|
loginUsernameInput to api.requiresUsername
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setupItems(dialog: AlertDialog): Unit = with(binding) {
|
||||||
|
loginEmailInput.isVisible = api.requiresEmail
|
||||||
|
loginPasswordInput.isVisible = api.requiresPassword
|
||||||
|
loginServerInput.isVisible = api.requiresServer
|
||||||
|
loginUsernameInput.isVisible = api.requiresUsername
|
||||||
|
|
||||||
|
createAccount.isGone = api.createAccountUrl.isNullOrBlank()
|
||||||
|
createAccount.setOnClickListener {
|
||||||
|
AcraApplication.openBrowser(
|
||||||
|
api.createAccountUrl ?: return@setOnClickListener, activity
|
||||||
|
)
|
||||||
|
|
||||||
|
dialog.dismissSafe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleStoresPasswordInPlainText(dialog: AlertDialog): Unit = with(binding) {
|
||||||
|
if (!api.storesPasswordInPlainText) return
|
||||||
|
|
||||||
|
api.getUserData()?.let { data ->
|
||||||
|
loginEmailInput.setText(data.email ?: "")
|
||||||
|
loginServerInput.setText(data.server ?: "")
|
||||||
|
loginUsernameInput.setText(data.username ?: "")
|
||||||
|
loginPasswordInput.setText(data.password ?: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.settings.helpers.settings.account
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.databinding.AddAccountInputOauthBinding
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||||
|
|
||||||
|
|
||||||
|
class InAppOAuth2DialogBuilder(
|
||||||
|
private val api: InAppOAuth2API,
|
||||||
|
private val activity: FragmentActivity,
|
||||||
|
) : DialogBuilder<AddAccountInputOauthBinding>(
|
||||||
|
api, activity, R.style.AlertDialogCustom,
|
||||||
|
AddAccountInputOauthBinding.inflate(activity.layoutInflater)
|
||||||
|
) {
|
||||||
|
override fun getCommonItems(dialog: AlertDialog) = with(binding) {
|
||||||
|
CommonDialogItems(dialog, text1, applyBtt, cancelBtt, null, infoButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getVisibilityMap(dialog: AlertDialog): Map<View, Boolean> = with(binding) {
|
||||||
|
mapOf(
|
||||||
|
loginFileName to api.requiresFilename,
|
||||||
|
loginClientId to api.requiresClientId,
|
||||||
|
loginClientSecret to api.requiresSecret,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setupItems(dialog: AlertDialog): Unit = with(binding) {
|
||||||
|
loginFileName.isVisible = api.requiresFilename
|
||||||
|
loginClientId.isVisible = api.requiresClientId
|
||||||
|
loginClientSecret.isVisible = api.requiresSecret
|
||||||
|
|
||||||
|
infoButton.isGone = api.infoUrl.isNullOrBlank()
|
||||||
|
infoButton.setOnClickListener {
|
||||||
|
api.infoUrl?.let { url -> openBrowser(url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun onLogin(dialog: AlertDialog): Unit = with(binding) {
|
||||||
|
// if (this == null) throw IllegalStateException("Login should be called after validation")
|
||||||
|
|
||||||
|
val ctx = this.root.context
|
||||||
|
|
||||||
|
val clientId = loginClientId.text.toString().ifBlank {
|
||||||
|
ctx.getString(R.string.debug_gdrive_clientId)
|
||||||
|
}
|
||||||
|
val clientSecret = loginClientSecret.text.toString().ifBlank {
|
||||||
|
ctx.getString(R.string.debug_gdrive_secret)
|
||||||
|
}
|
||||||
|
val syncFileName = loginFileName.text.toString().trim().ifBlank {
|
||||||
|
api.defaultFilenameValue
|
||||||
|
}
|
||||||
|
val redirectUrl = loginFileName.text.toString().trim().ifBlank {
|
||||||
|
api.defaultRedirectUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
ioSafe {
|
||||||
|
api.getAuthorizationToken(
|
||||||
|
this@InAppOAuth2DialogBuilder.activity,
|
||||||
|
InAppOAuth2API.LoginData(
|
||||||
|
clientId = clientId,
|
||||||
|
secret = clientSecret,
|
||||||
|
fileNameInput = syncFileName,
|
||||||
|
redirectUrl = redirectUrl,
|
||||||
|
syncFileId = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.dismissSafe()
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,8 +16,10 @@ import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.databinding.FragmentSetupLanguageBinding
|
import com.lagradost.cloudstream3.databinding.FragmentSetupLanguageBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
import com.lagradost.cloudstream3.plugins.PluginManager
|
import com.lagradost.cloudstream3.plugins.PluginManager
|
||||||
|
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||||
import com.lagradost.cloudstream3.ui.settings.appLanguages
|
import com.lagradost.cloudstream3.ui.settings.appLanguages
|
||||||
import com.lagradost.cloudstream3.ui.settings.getCurrentLocale
|
import com.lagradost.cloudstream3.ui.settings.getCurrentLocale
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||||
|
|
||||||
|
@ -51,6 +53,7 @@ class SetupFragmentLanguage : Fragment() {
|
||||||
|
|
||||||
val ctx = context ?: return@normalSafeApiCall
|
val ctx = context ?: return@normalSafeApiCall
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||||
|
.attachBackupListener(ctx.getSyncPrefs()).self
|
||||||
|
|
||||||
val arrayAdapter =
|
val arrayAdapter =
|
||||||
ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice)
|
ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice)
|
||||||
|
|
|
@ -14,6 +14,8 @@ import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.databinding.FragmentSetupLayoutBinding
|
import com.lagradost.cloudstream3.databinding.FragmentSetupLayoutBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||||
|
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
|
|
||||||
|
|
||||||
|
@ -44,6 +46,7 @@ class SetupFragmentLayout : Fragment() {
|
||||||
val ctx = context ?: return@normalSafeApiCall
|
val ctx = context ?: return@normalSafeApiCall
|
||||||
|
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||||
|
.attachBackupListener(ctx.getSyncPrefs()).self
|
||||||
|
|
||||||
val prefNames = resources.getStringArray(R.array.app_layout)
|
val prefNames = resources.getStringArray(R.array.app_layout)
|
||||||
val prefValues = resources.getIntArray(R.array.app_layout_values)
|
val prefValues = resources.getIntArray(R.array.app_layout_values)
|
||||||
|
|
|
@ -10,13 +10,14 @@ import androidx.core.util.forEach
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.TvType
|
import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.databinding.FragmentSetupMediaBinding
|
import com.lagradost.cloudstream3.databinding.FragmentSetupMediaBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||||
|
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||||
|
|
||||||
|
|
||||||
class SetupFragmentMedia : Fragment() {
|
class SetupFragmentMedia : Fragment() {
|
||||||
|
@ -45,6 +46,7 @@ class SetupFragmentMedia : Fragment() {
|
||||||
|
|
||||||
val ctx = context ?: return@normalSafeApiCall
|
val ctx = context ?: return@normalSafeApiCall
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||||
|
.attachBackupListener(ctx.getSyncPrefs()).self
|
||||||
|
|
||||||
val arrayAdapter =
|
val arrayAdapter =
|
||||||
ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice)
|
ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice)
|
||||||
|
|
|
@ -18,6 +18,8 @@ import com.lagradost.cloudstream3.databinding.FragmentSetupProviderLanguagesBind
|
||||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||||
|
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||||
|
|
||||||
class SetupFragmentProviderLanguage : Fragment() {
|
class SetupFragmentProviderLanguage : Fragment() {
|
||||||
var binding: FragmentSetupProviderLanguagesBinding? = null
|
var binding: FragmentSetupProviderLanguagesBinding? = null
|
||||||
|
@ -46,6 +48,7 @@ class SetupFragmentProviderLanguage : Fragment() {
|
||||||
val ctx = context ?: return@normalSafeApiCall
|
val ctx = context ?: return@normalSafeApiCall
|
||||||
|
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||||
|
.attachBackupListener(ctx.getSyncPrefs()).self
|
||||||
|
|
||||||
val arrayAdapter =
|
val arrayAdapter =
|
||||||
ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice)
|
ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice)
|
||||||
|
|
|
@ -31,6 +31,8 @@ import com.lagradost.cloudstream3.databinding.SubtitleSettingsBinding
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals
|
import com.lagradost.cloudstream3.ui.settings.Globals
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||||
|
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.setKey
|
import com.lagradost.cloudstream3.utils.DataStore.setKey
|
||||||
import com.lagradost.cloudstream3.utils.Event
|
import com.lagradost.cloudstream3.utils.Event
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
||||||
|
@ -458,6 +460,7 @@ class SubtitlesFragment : Fragment() {
|
||||||
subtitlesFilterSubLang.setOnCheckedChangeListener { _, b ->
|
subtitlesFilterSubLang.setOnCheckedChangeListener { _, b ->
|
||||||
context?.let { ctx ->
|
context?.let { ctx ->
|
||||||
PreferenceManager.getDefaultSharedPreferences(ctx)
|
PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||||
|
.attachBackupListener(ctx.getSyncPrefs()).self
|
||||||
.edit()
|
.edit()
|
||||||
.putBoolean(getString(R.string.filter_sub_lang_key), b)
|
.putBoolean(getString(R.string.filter_sub_lang_key), b)
|
||||||
.apply()
|
.apply()
|
||||||
|
|
|
@ -3,6 +3,7 @@ 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.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
@ -12,14 +13,18 @@ import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
|
||||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
|
import com.lagradost.cloudstream3.MainActivity.Companion.afterBackupRestoreEvent
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
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.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
|
||||||
|
@ -32,7 +37,10 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs
|
import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs
|
import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.mapper
|
import com.lagradost.cloudstream3.utils.DataStore.mapper
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.removeKeyRaw
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.setKeyRaw
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
|
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
|
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream
|
||||||
|
@ -44,11 +52,17 @@ import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
object BackupUtils {
|
object BackupUtils {
|
||||||
|
enum class RestoreSource {
|
||||||
|
DATA, SETTINGS, SYNC;
|
||||||
|
|
||||||
|
val prefix = "$name/"
|
||||||
|
val syncPrefix = "${BackupAPI.SYNC_HISTORY_PREFIX}$prefix"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* No sensitive or breaking data in the backup
|
* No sensitive or breaking data in the backup
|
||||||
* */
|
* */
|
||||||
private val nonTransferableKeys = listOf(
|
val nonTransferableKeys = listOf(
|
||||||
// When sharing backup we do not want to transfer what is essentially the password
|
// When sharing backup we do not want to transfer what is essentially the password
|
||||||
ANILIST_TOKEN_KEY,
|
ANILIST_TOKEN_KEY,
|
||||||
ANILIST_CACHED_LIST,
|
ANILIST_CACHED_LIST,
|
||||||
|
@ -59,6 +73,8 @@ object BackupUtils {
|
||||||
MAL_CACHED_LIST,
|
MAL_CACHED_LIST,
|
||||||
MAL_UNIXTIME_KEY,
|
MAL_UNIXTIME_KEY,
|
||||||
MAL_USER_KEY,
|
MAL_USER_KEY,
|
||||||
|
GoogleDriveApi.K.TOKEN.value,
|
||||||
|
GoogleDriveApi.K.IS_READY.value,
|
||||||
|
|
||||||
// The plugins themselves are not backed up
|
// The plugins themselves are not backed up
|
||||||
PLUGINS_KEY,
|
PLUGINS_KEY,
|
||||||
|
@ -82,6 +98,16 @@ object BackupUtils {
|
||||||
private var restoreFileSelector: ActivityResultLauncher<Array<String>>? = null
|
private var restoreFileSelector: ActivityResultLauncher<Array<String>>? = null
|
||||||
|
|
||||||
// Kinda hack, but I couldn't think of a better way
|
// Kinda hack, but I couldn't think of a better way
|
||||||
|
data class RestoreMapData(
|
||||||
|
val wantToRestore: MutableSet<String> = mutableSetOf(),
|
||||||
|
val successfulRestore: MutableSet<String> = mutableSetOf()
|
||||||
|
) {
|
||||||
|
fun addAll(data: RestoreMapData) {
|
||||||
|
wantToRestore.addAll(data.wantToRestore)
|
||||||
|
successfulRestore.addAll(data.successfulRestore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class BackupVars(
|
data class BackupVars(
|
||||||
@JsonProperty("_Bool") val _Bool: Map<String, Boolean>?,
|
@JsonProperty("_Bool") val _Bool: Map<String, Boolean>?,
|
||||||
@JsonProperty("_Int") val _Int: Map<String, Int>?,
|
@JsonProperty("_Int") val _Int: Map<String, Int>?,
|
||||||
|
@ -89,20 +115,62 @@ object BackupUtils {
|
||||||
@JsonProperty("_Float") val _Float: Map<String, Float>?,
|
@JsonProperty("_Float") val _Float: Map<String, Float>?,
|
||||||
@JsonProperty("_Long") val _Long: Map<String, Long>?,
|
@JsonProperty("_Long") val _Long: Map<String, Long>?,
|
||||||
@JsonProperty("_StringSet") val _StringSet: Map<String, Set<String>?>?,
|
@JsonProperty("_StringSet") val _StringSet: Map<String, Set<String>?>?,
|
||||||
|
) {
|
||||||
|
constructor() : this(
|
||||||
|
mapOf(),
|
||||||
|
mapOf(),
|
||||||
|
mapOf(),
|
||||||
|
mapOf(),
|
||||||
|
mapOf(),
|
||||||
|
mapOf(),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
data class BackupFile(
|
data class BackupFile(
|
||||||
@JsonProperty("datastore") val datastore: BackupVars,
|
@JsonProperty("datastore") val datastore: BackupVars,
|
||||||
@JsonProperty("settings") val settings: BackupVars
|
@JsonProperty("settings") val settings: BackupVars,
|
||||||
)
|
@JsonProperty("sync-meta") val syncMeta: BackupVars = BackupVars(),
|
||||||
|
) {
|
||||||
|
fun restore(
|
||||||
|
ctx: Context,
|
||||||
|
source: RestoreSource,
|
||||||
|
restoreKeys: Set<String>? = null
|
||||||
|
): RestoreMapData {
|
||||||
|
val data = getData(source)
|
||||||
|
val successfulRestore = RestoreMapData()
|
||||||
|
|
||||||
|
successfulRestore.addAll(ctx.restoreMap(data._Bool, source, restoreKeys))
|
||||||
|
successfulRestore.addAll(ctx.restoreMap(data._Int, source, restoreKeys))
|
||||||
|
successfulRestore.addAll(ctx.restoreMap(data._String, source, restoreKeys))
|
||||||
|
successfulRestore.addAll(ctx.restoreMap(data._Float, source, restoreKeys))
|
||||||
|
successfulRestore.addAll(ctx.restoreMap(data._Long, source, restoreKeys))
|
||||||
|
successfulRestore.addAll(ctx.restoreMap(data._StringSet, source, restoreKeys))
|
||||||
|
|
||||||
|
return successfulRestore
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getData(source: RestoreSource) = when (source) {
|
||||||
|
RestoreSource.SYNC -> syncMeta
|
||||||
|
RestoreSource.DATA -> datastore
|
||||||
|
RestoreSource.SETTINGS -> settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
private fun getBackup(context: Context?): BackupFile? {
|
fun getBackup(context: Context): BackupFile {
|
||||||
if (context == null) return null
|
val syncDataPrefs = context.getSyncPrefs().all.filter { it.key.isTransferable() }
|
||||||
|
|
||||||
val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() }
|
val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() }
|
||||||
val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() }
|
val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() }
|
||||||
|
|
||||||
|
val syncData = BackupVars(
|
||||||
|
syncDataPrefs.filter { it.value is Boolean } as? Map<String, Boolean>,
|
||||||
|
syncDataPrefs.filter { it.value is Int } as? Map<String, Int>,
|
||||||
|
syncDataPrefs.filter { it.value is String } as? Map<String, String>,
|
||||||
|
syncDataPrefs.filter { it.value is Float } as? Map<String, Float>,
|
||||||
|
syncDataPrefs.filter { it.value is Long } as? Map<String, Long>,
|
||||||
|
syncDataPrefs.filter { it.value as? Set<String> != null } as? Map<String, Set<String>>
|
||||||
|
)
|
||||||
|
|
||||||
val allDataSorted = BackupVars(
|
val allDataSorted = BackupVars(
|
||||||
allData.filter { it.value is Boolean } as? Map<String, Boolean>,
|
allData.filter { it.value is Boolean } as? Map<String, Boolean>,
|
||||||
allData.filter { it.value is Int } as? Map<String, Int>,
|
allData.filter { it.value is Int } as? Map<String, Int>,
|
||||||
|
@ -123,35 +191,51 @@ object BackupUtils {
|
||||||
|
|
||||||
return BackupFile(
|
return BackupFile(
|
||||||
allDataSorted,
|
allDataSorted,
|
||||||
allSettingsSorted
|
allSettingsSorted,
|
||||||
|
syncData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
fun restore(
|
fun Context.restore(backupFile: BackupFile, restoreKeys: Set<String>? = null) = restore(
|
||||||
context: Context?,
|
backupFile,
|
||||||
|
restoreKeys,
|
||||||
|
RestoreSource.SYNC,
|
||||||
|
RestoreSource.DATA,
|
||||||
|
RestoreSource.SETTINGS
|
||||||
|
)
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun Context.restore(
|
||||||
backupFile: BackupFile,
|
backupFile: BackupFile,
|
||||||
restoreSettings: Boolean,
|
restoreKeys: Set<String>? = null,
|
||||||
restoreDataStore: Boolean
|
vararg restoreSources: RestoreSource
|
||||||
) {
|
) {
|
||||||
if (context == null) return
|
Log.d(BackupAPI.LOG_KEY, "will restore keys = $restoreKeys")
|
||||||
if (restoreSettings) {
|
|
||||||
context.restoreMap(backupFile.settings._Bool, true)
|
for (restoreSource in restoreSources) {
|
||||||
context.restoreMap(backupFile.settings._Int, true)
|
val restoreData = RestoreMapData()
|
||||||
context.restoreMap(backupFile.settings._String, true)
|
|
||||||
context.restoreMap(backupFile.settings._Float, true)
|
restoreData.addAll(backupFile.restore(this, restoreSource, restoreKeys))
|
||||||
context.restoreMap(backupFile.settings._Long, true)
|
|
||||||
context.restoreMap(backupFile.settings._StringSet, true)
|
// we must remove keys that are not present
|
||||||
|
if (!restoreKeys.isNullOrEmpty()) {
|
||||||
|
Log.d(
|
||||||
|
BackupAPI.LOG_KEY,
|
||||||
|
"successfulRestore for src=[${restoreSource.name}]: ${restoreData.successfulRestore}"
|
||||||
|
)
|
||||||
|
val removedKeys = restoreData.wantToRestore - restoreData.successfulRestore
|
||||||
|
Log.d(
|
||||||
|
BackupAPI.LOG_KEY,
|
||||||
|
"removed keys for src=[${restoreSource.name}]: $removedKeys"
|
||||||
|
)
|
||||||
|
|
||||||
|
removedKeys.forEach { removeKeyRaw(it, restoreSource) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (restoreDataStore) {
|
Log.d(BackupAPI.LOG_KEY, "restore on ui event fired")
|
||||||
context.restoreMap(backupFile.datastore._Bool)
|
afterBackupRestoreEvent.invoke(Unit)
|
||||||
context.restoreMap(backupFile.datastore._Int)
|
|
||||||
context.restoreMap(backupFile.datastore._String)
|
|
||||||
context.restoreMap(backupFile.datastore._Float)
|
|
||||||
context.restoreMap(backupFile.datastore._Long)
|
|
||||||
context.restoreMap(backupFile.datastore._StringSet)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("SimpleDateFormat")
|
@SuppressLint("SimpleDateFormat")
|
||||||
|
@ -208,15 +292,7 @@ object BackupUtils {
|
||||||
val input = activity.contentResolver.openInputStream(uri)
|
val input = activity.contentResolver.openInputStream(uri)
|
||||||
?: return@ioSafe
|
?: return@ioSafe
|
||||||
|
|
||||||
val restoredValue =
|
activity.restore(mapper.readValue(input))
|
||||||
mapper.readValue<BackupFile>(input)
|
|
||||||
|
|
||||||
restore(
|
|
||||||
activity,
|
|
||||||
restoredValue,
|
|
||||||
restoreSettings = true,
|
|
||||||
restoreDataStore = true
|
|
||||||
)
|
|
||||||
activity.runOnUiThread { activity.recreate() }
|
activity.runOnUiThread { activity.recreate() }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
|
@ -256,14 +332,54 @@ object BackupUtils {
|
||||||
|
|
||||||
private fun <T> Context.restoreMap(
|
private fun <T> Context.restoreMap(
|
||||||
map: Map<String, T>?,
|
map: Map<String, T>?,
|
||||||
isEditingAppSettings: Boolean = false
|
restoreSource: RestoreSource,
|
||||||
) {
|
restoreKeys: Set<String>? = null
|
||||||
val editor = DataStore.editor(this, isEditingAppSettings)
|
): RestoreMapData {
|
||||||
map?.forEach {
|
val restoreOnlyThese = mutableSetOf<String>()
|
||||||
if (it.key.isTransferable()) {
|
val successfulRestore = mutableSetOf<String>()
|
||||||
editor.setKeyRaw(it.key, it.value)
|
|
||||||
|
if (!restoreKeys.isNullOrEmpty()) {
|
||||||
|
var prefixToMatch = restoreSource.syncPrefix
|
||||||
|
var prefixToRemove = prefixToMatch
|
||||||
|
|
||||||
|
if (restoreSource == RestoreSource.SYNC) {
|
||||||
|
prefixToMatch = BackupAPI.SYNC_HISTORY_PREFIX
|
||||||
|
prefixToRemove = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val restore = restoreKeys.filter {
|
||||||
|
it.startsWith(prefixToMatch)
|
||||||
|
}.map {
|
||||||
|
it.removePrefix(prefixToRemove)
|
||||||
}
|
}
|
||||||
editor.apply()
|
|
||||||
|
restoreOnlyThese.addAll(restore)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
map?.filter {
|
||||||
|
var isTransferable = it.key.withoutPrefix(restoreSource).isTransferable()
|
||||||
|
|
||||||
|
if (isTransferable && restoreOnlyThese.isNotEmpty()) {
|
||||||
|
isTransferable = restoreOnlyThese.contains(it.key.withoutPrefix(restoreSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTransferable) {
|
||||||
|
successfulRestore.add(it.key.withoutPrefix(restoreSource))
|
||||||
|
}
|
||||||
|
|
||||||
|
isTransferable
|
||||||
|
}?.forEach {
|
||||||
|
setKeyRaw(it.key.withoutPrefix(restoreSource), it.value, restoreSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RestoreMapData(
|
||||||
|
restoreOnlyThese,
|
||||||
|
successfulRestore
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun String.withoutPrefix(restoreSource: BackupUtils.RestoreSource) =
|
||||||
|
// will not remove sync prefix because it wont match (its not a bug its a feature ¯\_(ツ)_/¯ )
|
||||||
|
removePrefix(restoreSource.prefix)
|
||||||
|
|
|
@ -10,8 +10,10 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.getKeyClass
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKeyClass
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKeyClass
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.BackupAPI
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
import kotlin.reflect.KProperty
|
import kotlin.reflect.KProperty
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
|
||||||
const val DOWNLOAD_HEADER_CACHE = "download_header_cache"
|
const val DOWNLOAD_HEADER_CACHE = "download_header_cache"
|
||||||
|
|
||||||
|
@ -22,6 +24,7 @@ const val USER_SELECTED_HOMEPAGE_API = "home_api_used"
|
||||||
const val USER_PROVIDER_API = "user_custom_sites"
|
const val USER_PROVIDER_API = "user_custom_sites"
|
||||||
|
|
||||||
const val PREFERENCES_NAME = "rebuild_preference"
|
const val PREFERENCES_NAME = "rebuild_preference"
|
||||||
|
const val SYNC_PREFERENCES_NAME = "rebuild_sync_preference"
|
||||||
|
|
||||||
// TODO degelgate by value for get & set
|
// TODO degelgate by value for get & set
|
||||||
|
|
||||||
|
@ -29,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
|
||||||
|
@ -50,29 +54,8 @@ class PreferenceDelegate<T : Any>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** When inserting many keys use this function, this is because apply for every key is very expensive on memory */
|
|
||||||
data class Editor(
|
|
||||||
val editor : SharedPreferences.Editor
|
|
||||||
) {
|
|
||||||
/** Always remember to call apply after */
|
|
||||||
fun<T> setKeyRaw(path: String, value: T) {
|
|
||||||
when (value) {
|
|
||||||
is Boolean -> editor.putBoolean(path, value)
|
|
||||||
is Int -> editor.putInt(path, value)
|
|
||||||
is String -> editor.putString(path, value)
|
|
||||||
is Float -> editor.putFloat(path, value)
|
|
||||||
is Long -> editor.putLong(path, value)
|
|
||||||
(value as? Set<String> != null) -> editor.putStringSet(path, value as Set<String>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun apply() {
|
|
||||||
editor.apply()
|
|
||||||
System.gc()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
object DataStore {
|
object DataStore {
|
||||||
|
private val backupScheduler = Scheduler.createBackupScheduler()
|
||||||
val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
|
val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
|
||||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
|
||||||
|
|
||||||
|
@ -80,6 +63,14 @@ object DataStore {
|
||||||
return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getSyncPreferences(context: Context): SharedPreferences {
|
||||||
|
return context.getSharedPreferences(SYNC_PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Context.getSyncPrefs(): SharedPreferences {
|
||||||
|
return getSyncPreferences(this)
|
||||||
|
}
|
||||||
|
|
||||||
fun Context.getSharedPrefs(): SharedPreferences {
|
fun Context.getSharedPrefs(): SharedPreferences {
|
||||||
return getPreferences(this)
|
return getPreferences(this)
|
||||||
}
|
}
|
||||||
|
@ -88,10 +79,38 @@ object DataStore {
|
||||||
return "${folder}/${path}"
|
return "${folder}/${path}"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun editor(context : Context, isEditingAppSettings: Boolean = false) : Editor {
|
fun <T> Context.setKeyRaw(path: String, value: T, restoreSource: BackupUtils.RestoreSource) {
|
||||||
val editor: SharedPreferences.Editor =
|
try {
|
||||||
if (isEditingAppSettings) context.getDefaultSharedPrefs().edit() else context.getSharedPrefs().edit()
|
val editor = when (restoreSource) {
|
||||||
return Editor(editor)
|
BackupUtils.RestoreSource.DATA -> getSharedPrefs().edit()
|
||||||
|
BackupUtils.RestoreSource.SETTINGS -> getDefaultSharedPrefs().edit()
|
||||||
|
BackupUtils.RestoreSource.SYNC -> getSyncPrefs().edit()
|
||||||
|
}
|
||||||
|
|
||||||
|
when (value) {
|
||||||
|
is Boolean -> editor.putBoolean(path, value)
|
||||||
|
is Int -> editor.putInt(path, value)
|
||||||
|
is String -> editor.putString(path, value)
|
||||||
|
is Float -> editor.putFloat(path, value)
|
||||||
|
is Long -> editor.putLong(path, value)
|
||||||
|
(value as? Set<String> != null) -> editor.putStringSet(path, value as Set<String>)
|
||||||
|
}
|
||||||
|
editor.apply()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Context.removeKeyRaw(path: String, restoreSource: BackupUtils.RestoreSource) {
|
||||||
|
try {
|
||||||
|
when (restoreSource) {
|
||||||
|
BackupUtils.RestoreSource.DATA -> getSharedPrefs().edit()
|
||||||
|
BackupUtils.RestoreSource.SETTINGS -> getDefaultSharedPrefs().edit()
|
||||||
|
BackupUtils.RestoreSource.SYNC -> getSyncPrefs().edit()
|
||||||
|
}.remove(path).apply()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Context.getDefaultSharedPrefs(): SharedPreferences {
|
fun Context.getDefaultSharedPrefs(): SharedPreferences {
|
||||||
|
@ -119,9 +138,23 @@ object DataStore {
|
||||||
try {
|
try {
|
||||||
val prefs = getSharedPrefs()
|
val prefs = getSharedPrefs()
|
||||||
if (prefs.contains(path)) {
|
if (prefs.contains(path)) {
|
||||||
|
val oldValueExists = prefs.getString(path, null) != null
|
||||||
|
|
||||||
val editor: SharedPreferences.Editor = prefs.edit()
|
val editor: SharedPreferences.Editor = prefs.edit()
|
||||||
editor.remove(path)
|
editor.remove(path)
|
||||||
editor.apply()
|
editor.apply()
|
||||||
|
|
||||||
|
ioSafe {
|
||||||
|
backupScheduler.work(
|
||||||
|
BackupAPI.PreferencesSchedulerData(
|
||||||
|
getSyncPrefs(),
|
||||||
|
path,
|
||||||
|
oldValueExists,
|
||||||
|
false,
|
||||||
|
BackupUtils.RestoreSource.DATA
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
|
@ -138,9 +171,25 @@ object DataStore {
|
||||||
|
|
||||||
fun <T> Context.setKey(path: String, value: T) {
|
fun <T> Context.setKey(path: String, value: T) {
|
||||||
try {
|
try {
|
||||||
val editor: SharedPreferences.Editor = getSharedPrefs().edit()
|
val prefs = getSharedPrefs()
|
||||||
editor.putString(path, mapper.writeValueAsString(value))
|
val oldValue = prefs.getString(path, null)
|
||||||
|
val newValue = mapper.writeValueAsString(value)
|
||||||
|
|
||||||
|
val editor: SharedPreferences.Editor = prefs.edit()
|
||||||
|
editor.putString(path, newValue)
|
||||||
editor.apply()
|
editor.apply()
|
||||||
|
|
||||||
|
ioSafe {
|
||||||
|
backupScheduler.work(
|
||||||
|
BackupAPI.PreferencesSchedulerData(
|
||||||
|
getSyncPrefs(),
|
||||||
|
path,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
BackupUtils.RestoreSource.DATA
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
}
|
}
|
||||||
|
@ -159,6 +208,7 @@ object DataStore {
|
||||||
setKey(getFolderName(folder, path), value)
|
setKey(getFolderName(folder, path), value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
inline fun <reified T : Any> String.toKotlinObject(): T {
|
inline fun <reified T : Any> String.toKotlinObject(): T {
|
||||||
return mapper.readValue(this, T::class.java)
|
return mapper.readValue(this, T::class.java)
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,8 +14,11 @@ import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.*
|
import com.lagradost.cloudstream3.*
|
||||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||||
|
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import okio.BufferedSink
|
import okio.BufferedSink
|
||||||
|
@ -24,7 +27,6 @@ import okio.sink
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
|
import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
|
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
|
@ -75,6 +77,7 @@ class InAppUpdater {
|
||||||
private suspend fun Activity.getAppUpdate(): Update {
|
private suspend fun Activity.getAppUpdate(): Update {
|
||||||
return try {
|
return try {
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
||||||
|
.attachBackupListener(getSyncPrefs()).self
|
||||||
if (settingsManager.getBoolean(
|
if (settingsManager.getBoolean(
|
||||||
getString(R.string.prerelease_update_key),
|
getString(R.string.prerelease_update_key),
|
||||||
resources.getBoolean(R.bool.is_prerelease)
|
resources.getBoolean(R.bool.is_prerelease)
|
||||||
|
@ -256,7 +259,9 @@ class InAppUpdater {
|
||||||
* @param checkAutoUpdate if the update check was launched automatically
|
* @param checkAutoUpdate if the update check was launched automatically
|
||||||
**/
|
**/
|
||||||
suspend fun Activity.runAutoUpdate(checkAutoUpdate: Boolean = true): Boolean {
|
suspend fun Activity.runAutoUpdate(checkAutoUpdate: Boolean = true): Boolean {
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
val settingsManager =
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(this)
|
||||||
|
.attachBackupListener(getSyncPrefs()).self
|
||||||
|
|
||||||
if (!checkAutoUpdate || settingsManager.getBoolean(
|
if (!checkAutoUpdate || settingsManager.getBoolean(
|
||||||
getString(R.string.auto_update_key),
|
getString(R.string.auto_update_key),
|
||||||
|
@ -266,7 +271,8 @@ class InAppUpdater {
|
||||||
val update = getAppUpdate()
|
val update = getAppUpdate()
|
||||||
if (
|
if (
|
||||||
update.shouldUpdate &&
|
update.shouldUpdate &&
|
||||||
update.updateURL != null) {
|
update.updateURL != null
|
||||||
|
) {
|
||||||
|
|
||||||
// Check if update should be skipped
|
// Check if update should be skipped
|
||||||
val updateNodeId =
|
val updateNodeId =
|
||||||
|
|
175
app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt
Normal file
175
app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
package com.lagradost.cloudstream3.utils
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.BackupAPI
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.logHistoryChanged
|
||||||
|
import com.lagradost.cloudstream3.utils.BackupUtils.nonTransferableKeys
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
|
||||||
|
class Scheduler<INPUT>(
|
||||||
|
private val throttleTimeMs: Long,
|
||||||
|
private val onWork: suspend (INPUT) -> Unit,
|
||||||
|
private val beforeWork: (suspend (INPUT?) -> Unit)? = null,
|
||||||
|
private val canWork: (suspend (INPUT) -> Boolean)? = null
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
var SCHEDULER_ID = 1
|
||||||
|
|
||||||
|
// these will not run upload scheduler, however only `nonTransferableKeys` are not stored
|
||||||
|
private val invalidUploadTriggerKeys = listOf(
|
||||||
|
*nonTransferableKeys.toTypedArray(),
|
||||||
|
VideoDownloadManager.KEY_DOWNLOAD_INFO,
|
||||||
|
DOWNLOAD_HEADER_CACHE,
|
||||||
|
)
|
||||||
|
private val invalidUploadTriggerKeysRegex = listOf(
|
||||||
|
// These trigger automatically every time a show is opened, way too often.
|
||||||
|
Regex("""^\d+/$RESULT_SEASON/"""),
|
||||||
|
Regex("""^\d+/$RESULT_EPISODE/"""),
|
||||||
|
Regex("""^\d+/$RESULT_DUB/"""),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createBackupScheduler() = Scheduler<BackupAPI.PreferencesSchedulerData<*>>(
|
||||||
|
BackupAPI.UPLOAD_THROTTLE.inWholeMilliseconds,
|
||||||
|
onWork = { input ->
|
||||||
|
AccountManager.BackupApis.forEach { api ->
|
||||||
|
api.scheduleUpload(
|
||||||
|
input.storeKey,
|
||||||
|
input.source == BackupUtils.RestoreSource.SETTINGS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeWork = { _ ->
|
||||||
|
AccountManager.BackupApis.filter { api ->
|
||||||
|
api.getIsReady()
|
||||||
|
}.forEach {
|
||||||
|
it.setIsUploadingSoon()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
canWork = { input ->
|
||||||
|
val hasSomeActiveManagers = AccountManager.BackupApis.any { it.getIsReady() }
|
||||||
|
if (!hasSomeActiveManagers) {
|
||||||
|
return@Scheduler false
|
||||||
|
}
|
||||||
|
|
||||||
|
val valueDidNotChange = input.oldValue == input.newValue
|
||||||
|
if (valueDidNotChange) {
|
||||||
|
return@Scheduler false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not sync account preferences
|
||||||
|
val isAccountKey = AccountManager.accountManagers.any {
|
||||||
|
input.storeKey.startsWith("${it.accountId}/")
|
||||||
|
}
|
||||||
|
if (isAccountKey) {
|
||||||
|
return@Scheduler false
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasInvalidKey = invalidUploadTriggerKeys.any { key ->
|
||||||
|
input.storeKey.startsWith(key)
|
||||||
|
} || invalidUploadTriggerKeysRegex.any { keyRegex ->
|
||||||
|
input.storeKey.contains(keyRegex)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasInvalidKey) {
|
||||||
|
return@Scheduler false
|
||||||
|
}
|
||||||
|
|
||||||
|
input.syncPrefs.logHistoryChanged(input.storeKey, input.source)
|
||||||
|
return@Scheduler true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Common usage is `val settingsManager = PreferenceManager.getDefaultSharedPreferences(this).attachListener().self`
|
||||||
|
// which means it is mostly used for settings preferences, therefore we use `isSettings: Boolean = true`, be careful
|
||||||
|
// if you need to directly access `context.getSharedPreferences` (without using DataStore) and dont forget to turn it off
|
||||||
|
fun SharedPreferences.attachBackupListener(
|
||||||
|
source: BackupUtils.RestoreSource = BackupUtils.RestoreSource.SETTINGS,
|
||||||
|
syncPrefs: SharedPreferences
|
||||||
|
): BackupAPI.SharedPreferencesWithListener {
|
||||||
|
val scheduler = createBackupScheduler()
|
||||||
|
|
||||||
|
var lastValue = all
|
||||||
|
registerOnSharedPreferenceChangeListener { sharedPreferences, storeKey ->
|
||||||
|
if (storeKey == null) return@registerOnSharedPreferenceChangeListener
|
||||||
|
ioSafe {
|
||||||
|
scheduler.work(
|
||||||
|
BackupAPI.PreferencesSchedulerData(
|
||||||
|
syncPrefs,
|
||||||
|
storeKey,
|
||||||
|
lastValue[storeKey],
|
||||||
|
sharedPreferences.all[storeKey],
|
||||||
|
source
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
lastValue = sharedPreferences.all
|
||||||
|
}
|
||||||
|
|
||||||
|
return BackupAPI.SharedPreferencesWithListener(this, scheduler)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SharedPreferences.attachBackupListener(syncPrefs: SharedPreferences): BackupAPI.SharedPreferencesWithListener {
|
||||||
|
return attachBackupListener(BackupUtils.RestoreSource.SETTINGS, syncPrefs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val id = SCHEDULER_ID++
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private var runnable: Runnable? = null
|
||||||
|
|
||||||
|
suspend fun work(input: INPUT): Boolean {
|
||||||
|
if (canWork?.invoke(input) == false) {
|
||||||
|
// Log.d(LOG_KEY, "[$id] cannot schedule [${input}]")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(BackupAPI.LOG_KEY, "[$id] wants to schedule [${input}]")
|
||||||
|
beforeWork?.invoke(input)
|
||||||
|
throttle(input)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun workNow(input: INPUT): Boolean {
|
||||||
|
if (canWork?.invoke(input) == false) {
|
||||||
|
Log.d(BackupAPI.LOG_KEY, "[$id] cannot run immediate [${input}]")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Log.d(BackupAPI.LOG_KEY, "[$id] runs immediate [${input}]")
|
||||||
|
beforeWork?.invoke(input)
|
||||||
|
stop()
|
||||||
|
onWork(input)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
runnable?.let {
|
||||||
|
handler.removeCallbacks(it)
|
||||||
|
runnable = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevents spamming the service by only allowing one job every throttleTimeMs
|
||||||
|
* @see throttleTimeMs
|
||||||
|
*/
|
||||||
|
private suspend fun throttle(input: INPUT) {
|
||||||
|
stop()
|
||||||
|
|
||||||
|
runnable = Runnable {
|
||||||
|
Log.d(BackupAPI.LOG_KEY, "[$id] schedule success")
|
||||||
|
ioSafe {
|
||||||
|
onWork(input)
|
||||||
|
}
|
||||||
|
}.also { run ->
|
||||||
|
handler.postDelayed(run, throttleTimeMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
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>
|
17
app/src/main/res/drawable/ic_baseline_add_to_drive_24.xml
Normal file
17
app/src/main/res/drawable/ic_baseline_add_to_drive_24.xml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?attr/white"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/white"
|
||||||
|
android:pathData="M20,21v-3h3v-2h-3v-3h-2v3h-3v2h3v3H20zM15.03,21.5H5.66c-0.72,0 -1.38,-0.38 -1.73,-1L1.57,16.4c-0.36,-0.62 -0.35,-1.38 0.01,-2L7.92,3.49C8.28,2.88 8.94,2.5 9.65,2.5h4.7c0.71,0 1.37,0.38 1.73,0.99l4.48,7.71C20.06,11.07 19.54,11 19,11c-0.28,0 -0.56,0.02 -0.84,0.06L14.35,4.5h-4.7L3.31,15.41l2.35,4.09h7.89C13.9,20.27 14.4,20.95 15.03,21.5zM13.34,15C13.12,15.63 13,16.3 13,17H7.25l-0.73,-1.27l4.58,-7.98h1.8l2.53,4.42c-0.56,0.42 -1.05,0.93 -1.44,1.51l-2,-3.49L9.25,15H13.34z"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/black"
|
||||||
|
android:pathData="M20,21v-3h3v-2h-3v-3h-2v3h-3v2h3v3H20zM15.03,21.5H5.66c-0.72,0 -1.38,-0.38 -1.73,-1L1.57,16.4c-0.36,-0.62 -0.35,-1.38 0.01,-2L7.92,3.49C8.28,2.88 8.94,2.5 9.65,2.5h4.7c0.71,0 1.37,0.38 1.73,0.99l4.48,7.71C20.06,11.07 19.54,11 19,11c-0.28,0 -0.56,0.02 -0.84,0.06L14.35,4.5h-4.7L3.31,15.41l2.35,4.09h7.89C13.9,20.27 14.4,20.95 15.03,21.5zM13.34,15C13.12,15.63 13,16.3 13,17H7.25l-0.73,-1.27l4.58,-7.98h1.8l2.53,4.42c-0.56,0.42 -1.05,0.93 -1.44,1.51l-2,-3.49L9.25,15H13.34z"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
|
||||||
|
</vector>
|
153
app/src/main/res/layout/add_account_input_oauth.xml
Normal file
153
app/src/main/res/layout/add_account_input_oauth.xml
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:layout_marginBottom="10dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/text1"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
|
||||||
|
android:layout_rowWeight="1"
|
||||||
|
android:layout_gravity="center_vertical"
|
||||||
|
|
||||||
|
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
|
||||||
|
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
|
||||||
|
android:textColor="?attr/textColor"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
tools:text="Test" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/info_button"
|
||||||
|
style="@style/WhiteButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_gravity="center_vertical|end"
|
||||||
|
android:text="@string/info_button"
|
||||||
|
app:icon="@drawable/ic_outline_info_24" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="10dp"
|
||||||
|
android:layout_marginBottom="60dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/example_login_client_id"/>
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/login_client_id"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/example_login_client_id"
|
||||||
|
android:inputType="textEmailAddress"
|
||||||
|
android:nextFocusLeft="@id/apply_btt"
|
||||||
|
android:nextFocusRight="@id/cancel_btt"
|
||||||
|
|
||||||
|
android:nextFocusDown="@id/login_client_secret"
|
||||||
|
android:requiresFadingEdge="vertical"
|
||||||
|
android:textColorHint="?attr/grayTextColor"
|
||||||
|
tools:ignore="LabelFor"
|
||||||
|
android:importantForAutofill="no" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/example_login_client_secret"/>
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/login_client_secret"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/example_login_client_secret"
|
||||||
|
android:inputType="textVisiblePassword"
|
||||||
|
android:nextFocusLeft="@id/apply_btt"
|
||||||
|
|
||||||
|
android:nextFocusRight="@id/cancel_btt"
|
||||||
|
android:nextFocusUp="@id/login_client_id"
|
||||||
|
android:nextFocusDown="@id/login_file_name"
|
||||||
|
android:requiresFadingEdge="vertical"
|
||||||
|
android:textColorHint="?attr/grayTextColor"
|
||||||
|
tools:ignore="LabelFor"
|
||||||
|
android:importantForAutofill="no" />
|
||||||
|
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/example_login_file_name_full"/>
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/login_file_name"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/example_login_file_name"
|
||||||
|
android:inputType="text"
|
||||||
|
android:nextFocusLeft="@id/apply_btt"
|
||||||
|
android:nextFocusRight="@id/cancel_btt"
|
||||||
|
android:nextFocusUp="@id/login_client_secret"
|
||||||
|
android:nextFocusDown="@id/login_redirect_url"
|
||||||
|
android:requiresFadingEdge="vertical"
|
||||||
|
android:textColorHint="?attr/grayTextColor"
|
||||||
|
android:importantForAutofill="no" />
|
||||||
|
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/example_login_redirect_url_full"/>
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/login_redirect_url"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/example_redirect_url"
|
||||||
|
android:inputType="textUri"
|
||||||
|
android:nextFocusLeft="@id/apply_btt"
|
||||||
|
android:nextFocusRight="@id/cancel_btt"
|
||||||
|
android:nextFocusUp="@id/login_file_name"
|
||||||
|
android:nextFocusDown="@id/apply_btt"
|
||||||
|
android:requiresFadingEdge="vertical"
|
||||||
|
android:textColorHint="?attr/grayTextColor"
|
||||||
|
android:importantForAutofill="no" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/apply_btt_holder"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="60dp"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:layout_marginTop="-60dp"
|
||||||
|
android:gravity="bottom|end"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/apply_btt"
|
||||||
|
style="@style/WhiteButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_gravity="center_vertical|end"
|
||||||
|
android:text="@string/login" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/cancel_btt"
|
||||||
|
style="@style/BlackButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_gravity="center_vertical|end"
|
||||||
|
android:text="@string/cancel" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
|
@ -44,9 +44,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"
|
||||||
|
@ -54,6 +55,16 @@
|
||||||
android:textSize="18sp"
|
android:textSize="18sp"
|
||||||
android:textStyle="normal"
|
android:textStyle="normal"
|
||||||
tools:text="Quick Brown Fox" />
|
tools:text="Quick Brown Fox" />
|
||||||
|
|
||||||
|
<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
|
||||||
|
|
|
@ -469,11 +469,15 @@
|
||||||
<string name="bottom_title_settings">Poster title location</string>
|
<string name="bottom_title_settings">Poster title location</string>
|
||||||
<string name="bottom_title_settings_des">Put the title under the poster</string>
|
<string name="bottom_title_settings_des">Put the title under the poster</string>
|
||||||
<!-- account stuff -->
|
<!-- account stuff -->
|
||||||
|
<string name="settings_category_plugins">Plugins</string>
|
||||||
|
<string name="settings_category_remote_sync">Remote Sync</string>
|
||||||
<string name="anilist_key" translatable="false">anilist_key</string>
|
<string name="anilist_key" translatable="false">anilist_key</string>
|
||||||
<string name="simkl_key" translatable="false">simkl_key</string>
|
<string name="simkl_key" translatable="false">simkl_key</string>
|
||||||
<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="subdl_key" translatable="false">subdl_key</string>
|
<string name="subdl_key" translatable="false">subdl_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">Username</string>
|
<string name="example_username">Username</string>
|
||||||
|
@ -482,6 +486,9 @@
|
||||||
<string name="example_site_name">NewSiteName</string>
|
<string name="example_site_name">NewSiteName</string>
|
||||||
<string name="example_site_url">https://example.com</string>
|
<string name="example_site_url">https://example.com</string>
|
||||||
<string name="example_lang_name">Language code (en)</string>
|
<string name="example_lang_name">Language code (en)</string>
|
||||||
|
<string name="example_login_file_name" translatable="false">cloudstreamapp-sync-file</string>
|
||||||
|
<string name="example_login_client_id">OAuth Client ID</string>
|
||||||
|
<string name="example_login_client_secret">OAuth Client Secret</string>
|
||||||
<!--
|
<!--
|
||||||
<string name="mal_account_settings" translatable="false">MAL</string>
|
<string name="mal_account_settings" translatable="false">MAL</string>
|
||||||
<string name="anilist_account_settings" translatable="false">AniList</string>
|
<string name="anilist_account_settings" translatable="false">AniList</string>
|
||||||
|
@ -721,6 +728,12 @@
|
||||||
<string name="qualities">Qualities</string>
|
<string name="qualities">Qualities</string>
|
||||||
<string name="profile_background_des">Profile background</string>
|
<string name="profile_background_des">Profile background</string>
|
||||||
<string name="unable_to_inflate">UI was unable to be created correctly, this is a MAJOR BUG and should be reported immediately %s</string>
|
<string name="unable_to_inflate">UI was unable to be created correctly, this is a MAJOR BUG and should be reported immediately %s</string>
|
||||||
|
<string name="example_login_file_name_full">Sync file name (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="info_button">Info</string>
|
||||||
|
<string name="sync_data">Sync data</string>
|
||||||
|
<string name="syncing_data">Syncing data</string>
|
||||||
<string name="already_voted">You have already voted</string>
|
<string name="already_voted">You have already voted</string>
|
||||||
<string name="favorites_list_name">Favorites</string>
|
<string name="favorites_list_name">Favorites</string>
|
||||||
<string name="favorite_added">%s added to favorites</string>
|
<string name="favorite_added">%s added to favorites</string>
|
||||||
|
|
|
@ -1,37 +1,35 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<Preference
|
<Preference
|
||||||
android:icon="@drawable/mal_logo"
|
android:icon="@drawable/mal_logo"
|
||||||
android:key="@string/mal_key" />
|
android:key="@string/mal_key" />
|
||||||
|
|
||||||
<Preference
|
<Preference
|
||||||
android:icon="@drawable/ic_anilist_icon"
|
android:icon="@drawable/ic_anilist_icon"
|
||||||
android:key="@string/anilist_key" />
|
android:key="@string/anilist_key" />
|
||||||
|
|
||||||
<Preference
|
|
||||||
android:icon="@drawable/simkl_logo"
|
|
||||||
android:key="@string/simkl_key" />
|
|
||||||
|
|
||||||
<Preference
|
<Preference
|
||||||
android:icon="@drawable/open_subtitles_icon"
|
android:icon="@drawable/open_subtitles_icon"
|
||||||
android:key="@string/opensubtitles_key" />
|
android:key="@string/opensubtitles_key" />
|
||||||
|
|
||||||
<Preference
|
<Preference
|
||||||
android:icon="@drawable/subdl_logo_big"
|
android:icon="@drawable/subdl_logo_big"
|
||||||
android:key="@string/subdl_key" />
|
android:key="@string/subdl_key" />
|
||||||
|
<Preference
|
||||||
|
android:icon="@drawable/simkl_logo"
|
||||||
|
android:key="@string/simkl_key" />
|
||||||
|
<Preference
|
||||||
|
android:icon="@drawable/baseline_cloud_24"
|
||||||
|
android:key="@string/pcloud_key" />
|
||||||
|
<Preference
|
||||||
|
android:icon="@drawable/ic_baseline_add_to_drive_24"
|
||||||
|
android:key="@string/gdrive_key" />
|
||||||
<SwitchPreference
|
<SwitchPreference
|
||||||
android:defaultValue="false"
|
android:defaultValue="false"
|
||||||
android:icon="@drawable/ic_outline_account_circle_24"
|
android:icon="@drawable/ic_outline_account_circle_24"
|
||||||
android:key="@string/skip_startup_account_select_key"
|
android:key="@string/skip_startup_account_select_key"
|
||||||
android:title="@string/skip_startup_account_select_pref" />
|
android:title="@string/skip_startup_account_select_pref" />
|
||||||
|
|
||||||
<SwitchPreferenceCompat
|
<SwitchPreferenceCompat
|
||||||
android:key="@string/biometric_key"
|
|
||||||
android:defaultValue="false"
|
android:defaultValue="false"
|
||||||
android:summary="@string/biometric_setting_summary"
|
|
||||||
android:icon="@drawable/ic_fingerprint"
|
android:icon="@drawable/ic_fingerprint"
|
||||||
|
android:key="@string/biometric_key"
|
||||||
|
android:summary="@string/biometric_setting_summary"
|
||||||
android:title="@string/biometric_setting" />
|
android:title="@string/biometric_setting" />
|
||||||
|
|
||||||
</PreferenceScreen>
|
</PreferenceScreen>
|
Loading…
Add table
Add a link
Reference in a new issue