mirror of
https://github.com/recloudstream/cloudstream.git
synced 2024-08-15 01:53:11 +00:00
Merge branch 'feature/remote-sync' into sync
This commit is contained in:
commit
361a5bb62a
34 changed files with 1825 additions and 87 deletions
.gitignore
app
build.gradle.kts
src
debug/res/drawable
main
AndroidManifest.xml
java/com/lagradost/cloudstream3
MainActivity.kt
syncproviders
ui
home
library
player
settings
SettingsAccount.ktSettingsGeneral.ktSettingsPlayer.ktSettingsProviders.ktSettingsUI.ktSettingsUpdates.kt
helpers/settings/account
setup
subtitles
utils
res
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -14,3 +14,4 @@
|
|||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
/.idea
|
|
@ -2,6 +2,8 @@ import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
|
|||
import org.jetbrains.dokka.gradle.DokkaTask
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.URL
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
|
@ -23,6 +25,15 @@ fun String.execute() = ByteArrayOutputStream().use { baot ->
|
|||
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 {
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
|
@ -107,6 +118,16 @@ android {
|
|||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
resValue(
|
||||
"string",
|
||||
"debug_gdrive_secret",
|
||||
localProperties.getProperty("debug.gdrive.secret") ?: ""
|
||||
)
|
||||
resValue(
|
||||
"string",
|
||||
"debug_gdrive_clientId",
|
||||
localProperties.getProperty("debug.gdrive.clientId") ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
flavorDimensions.add("state")
|
||||
|
@ -260,8 +281,27 @@ dependencies {
|
|||
|
||||
// color palette for images -> colors
|
||||
implementation("androidx.palette:palette-ktx:1.0.0")
|
||||
|
||||
implementation("org.skyscreamer:jsonassert:1.2.3")
|
||||
implementation("androidx.browser:browser:1.4.0")
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tasks.register("androidSourcesJar", Jar::class) {
|
||||
archiveClassifier.set("sources")
|
||||
from(android.sourceSets.getByName("main").java.srcDirs) //full sources
|
||||
|
|
17
app/src/debug/res/drawable/ic_baseline_add_to_drive_24.xml
Normal file
17
app/src/debug/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>
|
|
@ -159,6 +159,17 @@
|
|||
android:pathPrefix="/"
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
|
||||
<data android:scheme="cloudstreamapp" />
|
||||
<data android:host="oauth" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
|
|
|
@ -81,6 +81,7 @@ import com.lagradost.cloudstream3.plugins.PluginManager
|
|||
import com.lagradost.cloudstream3.plugins.PluginManager.loadAllOnlinePlugins
|
||||
import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin
|
||||
import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.BackupApis
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString
|
||||
|
@ -303,6 +304,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
val mainPluginsLoadedEvent =
|
||||
Event<Boolean>() // homepage api, used to speed up time to load for homepage
|
||||
val afterRepositoryLoadedEvent = Event<Boolean>()
|
||||
val afterBackupRestoreEvent = Event<Unit>()
|
||||
|
||||
// kinda shitty solution, but cant com main->home otherwise for popups
|
||||
val bookmarksUpdatedEvent = Event<Boolean>()
|
||||
|
@ -681,6 +683,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java)
|
||||
this.sendBroadcast(broadcastIntent)
|
||||
afterPluginsLoadedEvent -= ::onAllPluginsLoaded
|
||||
// run sync before app quits
|
||||
BackupApis.forEach { it.addToQueueNow() }
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.lagradost.cloudstream3.syncproviders
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
|
@ -12,6 +13,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
|||
val aniListApi = AniListApi(0)
|
||||
val openSubtitlesApi = OpenSubtitlesApi(0)
|
||||
val simklApi = SimklApi(0)
|
||||
val googleDriveApi = GoogleDriveApi(0)
|
||||
val indexSubtitlesApi = IndexSubtitleApi()
|
||||
val addic7ed = Addic7ed()
|
||||
val localListApi = LocalList()
|
||||
|
@ -19,13 +21,13 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
|||
// used to login via app intent
|
||||
val OAuth2Apis
|
||||
get() = listOf<OAuth2API>(
|
||||
malApi, aniListApi, simklApi
|
||||
malApi, aniListApi, simklApi, googleDriveApi
|
||||
)
|
||||
|
||||
// this needs init with context and can be accessed in settings
|
||||
val accountManagers
|
||||
get() = listOf(
|
||||
malApi, aniListApi, openSubtitlesApi, simklApi //nginxApi
|
||||
malApi, aniListApi, openSubtitlesApi, simklApi, googleDriveApi //, nginxApi
|
||||
)
|
||||
|
||||
// used for active syncing
|
||||
|
@ -34,8 +36,16 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
|||
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi)
|
||||
)
|
||||
|
||||
// used for active backup
|
||||
val BackupApis
|
||||
get() = listOf<BackupAPI<*>>(
|
||||
googleDriveApi
|
||||
)
|
||||
|
||||
val inAppAuths
|
||||
get() = listOf(openSubtitlesApi)//, nginxApi)
|
||||
get() = listOf(
|
||||
openSubtitlesApi, googleDriveApi//, nginxApi
|
||||
)
|
||||
|
||||
val subtitleProviders
|
||||
get() = listOf(
|
||||
|
@ -90,6 +100,12 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
|||
// int array of all accounts indexes
|
||||
private val accountsKey get() = "${idPrefix}_accounts"
|
||||
|
||||
|
||||
// runs on startup
|
||||
@WorkerThread
|
||||
open suspend fun initialize() {
|
||||
}
|
||||
|
||||
protected fun removeAccountKeys() {
|
||||
removeKeys(accountId)
|
||||
val accounts = getAccounts()?.toMutableList() ?: mutableListOf()
|
||||
|
|
|
@ -0,0 +1,210 @@
|
|||
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.mvvm.launchSafe
|
||||
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.DataStore
|
||||
import com.lagradost.cloudstream3.utils.Scheduler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import org.skyscreamer.jsonassert.JSONCompare
|
||||
import org.skyscreamer.jsonassert.JSONCompareMode
|
||||
import org.skyscreamer.jsonassert.JSONCompareResult
|
||||
import kotlin.system.measureTimeMillis
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
interface BackupAPI<LOGIN_DATA> {
|
||||
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"
|
||||
const val SYNC_HISTORY_PREFIX = "_hs/"
|
||||
|
||||
// Can be called in high frequency (for now) because current implementation uses google
|
||||
// cloud project per user so there is no way to hit quota. Later we should implement
|
||||
// some kind of adaptive throttling which will increase decrease throttle time based
|
||||
// on factors like: live devices, quota limits, etc
|
||||
val UPLOAD_THROTTLE = 10.seconds
|
||||
val DOWNLOAD_THROTTLE = 60.seconds
|
||||
// add to queue may be called frequently
|
||||
private val ioScope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
fun SharedPreferences.logHistoryChanged(path: String, source: BackupUtils.RestoreSource) {
|
||||
edit().putLong("${source.syncPrefix}$path", System.currentTimeMillis())
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* isActive is recommended to be overridden to verifiy if BackupApi is being used. if manager
|
||||
* is not set up it won't write sync data.
|
||||
* @see Scheduler.Companion.createBackupScheduler
|
||||
* @see SharedPreferences.logHistoryChanged
|
||||
*/
|
||||
var isActive: Boolean?
|
||||
fun updateApiActiveState() {
|
||||
this.isActive = this.isActive()
|
||||
}
|
||||
fun isActive(): Boolean
|
||||
/**
|
||||
* Should download data from API and call Context.mergeBackup(incomingData: String). If data
|
||||
* does not exist on the api uploadSyncData() is recommended to call. Should be called with
|
||||
* overwrite=true when user ads new account so it would accept changes from API
|
||||
* @see Context.mergeBackup
|
||||
* @see uploadSyncData
|
||||
*/
|
||||
fun downloadSyncData(overwrite: Boolean)
|
||||
|
||||
/**
|
||||
* Should upload data to API and call Context.createBackup(loginData: LOGIN_DATA)
|
||||
* @see Context.createBackup(loginData: LOGIN_DATA)
|
||||
*/
|
||||
fun uploadSyncData()
|
||||
|
||||
|
||||
fun Context.createBackup(loginData: LOGIN_DATA)
|
||||
fun Context.mergeBackup(incomingData: String, overwrite: Boolean) {
|
||||
val newData = DataStore.mapper.readValue<BackupUtils.BackupFile>(incomingData)
|
||||
if (overwrite) {
|
||||
Log.d(LOG_KEY, "overwriting data")
|
||||
restore(newData)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
val keysToUpdate = getKeysToUpdate(getBackup(), newData)
|
||||
if (keysToUpdate.isEmpty()) {
|
||||
Log.d(LOG_KEY, "remote data is up to date, sync not needed")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Log.d(LOG_KEY, incomingData)
|
||||
restore(newData, keysToUpdate)
|
||||
}
|
||||
|
||||
var willQueueSoon: Boolean?
|
||||
var uploadJob: Job?
|
||||
fun shouldUpdate(changedKey: String, isSettings: Boolean): Boolean
|
||||
fun addToQueue(changedKey: String, isSettings: Boolean) {
|
||||
|
||||
if (!shouldUpdate(changedKey, isSettings)) {
|
||||
willQueueSoon = false
|
||||
Log.d(LOG_KEY, "upload not required, data is same")
|
||||
return
|
||||
}
|
||||
|
||||
addToQueueNow()
|
||||
}
|
||||
fun addToQueueNow() {
|
||||
if (uploadJob != null && uploadJob!!.isActive) {
|
||||
Log.d(LOG_KEY, "upload is canceled, scheduling new")
|
||||
uploadJob?.cancel()
|
||||
}
|
||||
|
||||
uploadJob = ioScope.launchSafe {
|
||||
willQueueSoon = false
|
||||
Log.d(LOG_KEY, "upload is running now")
|
||||
uploadSyncData()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fun getKeysToUpdate(
|
||||
currentData: BackupUtils.BackupFile,
|
||||
newData: BackupUtils.BackupFile
|
||||
): Set<String> {
|
||||
val currentSync = getSyncKeys(currentData)
|
||||
val newSync = getSyncKeys(newData)
|
||||
|
||||
val changedKeys = newSync.filter {
|
||||
val localTimestamp = currentSync[it.key] ?: 0L
|
||||
it.value > localTimestamp
|
||||
}.keys
|
||||
|
||||
val onlyLocalKeys = currentSync.keys.filter { !newSync.containsKey(it) }
|
||||
val missingKeys = getAllMissingKeys(currentData, newData)
|
||||
|
||||
return (missingKeys + onlyLocalKeys + changedKeys).toSet()
|
||||
}
|
||||
|
||||
private fun getSyncKeys(data: BackupUtils.BackupFile) =
|
||||
data.syncMeta._Long.orEmpty().filter { it.key.startsWith(SYNC_HISTORY_PREFIX) }
|
||||
|
||||
|
||||
private fun getAllMissingKeys(
|
||||
old: BackupUtils.BackupFile,
|
||||
new: BackupUtils.BackupFile
|
||||
): List<String> = BackupUtils.RestoreSource
|
||||
.values()
|
||||
.filter { it != BackupUtils.RestoreSource.SYNC }
|
||||
.fold(mutableListOf()) { acc, source ->
|
||||
acc.addAll(getMissingKeysPrefixed(source, old, new))
|
||||
acc
|
||||
}
|
||||
|
||||
private fun getMissingKeysPrefixed(
|
||||
restoreSource: BackupUtils.RestoreSource,
|
||||
old: BackupUtils.BackupFile,
|
||||
new: BackupUtils.BackupFile
|
||||
): List<String> {
|
||||
val oldSource = old.getData(restoreSource)
|
||||
val newSource = new.getData(restoreSource)
|
||||
val prefixToMatch = restoreSource.syncPrefix
|
||||
|
||||
return listOf(
|
||||
*getMissing(oldSource._Bool, newSource._Bool),
|
||||
*getMissing(oldSource._Long, newSource._Long),
|
||||
*getMissing(oldSource._Float, newSource._Float),
|
||||
*getMissing(oldSource._Int, newSource._Int),
|
||||
*getMissing(oldSource._String, newSource._String),
|
||||
*getMissing(oldSource._StringSet, newSource._StringSet),
|
||||
).map {
|
||||
prefixToMatch + it
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun getMissing(old: Map<String, *>?, new: Map<String, *>?): Array<String> =
|
||||
(new.orEmpty().keys - old.orEmpty().keys)
|
||||
.toTypedArray()
|
||||
|
||||
}
|
|
@ -35,11 +35,6 @@ abstract class InAppAuthAPIManager(defIndex: Int) : AccountManager(defIndex), In
|
|||
override val storesPasswordInPlainText = true
|
||||
override val requiresLogin = true
|
||||
|
||||
// runs on startup
|
||||
@WorkerThread
|
||||
open suspend fun initialize() {
|
||||
}
|
||||
|
||||
override fun logOut() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
package com.lagradost.cloudstream3.syncproviders
|
||||
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.lagradost.cloudstream3.AcraApplication
|
||||
|
||||
interface InAppOAuth2API : OAuth2API {
|
||||
data class LoginData(
|
||||
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?
|
||||
}
|
||||
|
||||
abstract class InAppOAuth2APIManager(defIndex: Int) : AccountManager(defIndex), InAppOAuth2API {
|
||||
enum class K {
|
||||
LOGIN_DATA,
|
||||
IS_READY,
|
||||
TOKEN,
|
||||
;
|
||||
|
||||
val value: String = "data_oauth2_$name"
|
||||
}
|
||||
|
||||
protected fun <T> storeValue(key: K, value: T) = AcraApplication.setKey(
|
||||
accountId, key.value, value
|
||||
)
|
||||
|
||||
protected fun clearValue(key: K) = AcraApplication.removeKey(
|
||||
accountId, key.value
|
||||
)
|
||||
|
||||
protected inline fun <reified T : Any> getValue(key: K) = AcraApplication.getKey<T>(
|
||||
accountId, key.value
|
||||
)
|
||||
|
||||
override val requiresLogin = true
|
||||
override val createAccountUrl = null
|
||||
|
||||
override fun logOut() {
|
||||
K.values().forEach { clearValue(it) }
|
||||
removeAccountKeys()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,431 @@
|
|||
package com.lagradost.cloudstream3.syncproviders.providers
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
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.CommonActivity
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.BackupAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.BackupAPI.Companion.LOG_KEY
|
||||
import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API
|
||||
import com.lagradost.cloudstream3.syncproviders.InAppOAuth2APIManager
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
import com.lagradost.cloudstream3.utils.BackupUtils.getBackup
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.Scheduler
|
||||
import kotlinx.coroutines.Job
|
||||
import java.io.InputStream
|
||||
import java.util.Date
|
||||
|
||||
|
||||
/**
|
||||
* ## Improvements and ideas
|
||||
*
|
||||
* | State | Priority | Description
|
||||
* |---------:|:--------:|---------------------------------------------------------------------
|
||||
* | Someday | 4 | Add button to manually trigger sync
|
||||
* | Someday | 5 | Choose what should be synced and recheck `invalidKeys` in createBackupScheduler
|
||||
* | Someday | 3 | Add option to use proper OAuth through Google Services One Tap
|
||||
* | Someday | 5 | Encrypt data on Drive (low priority)
|
||||
* | 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) :
|
||||
InAppOAuth2APIManager(index),
|
||||
BackupAPI<InAppOAuth2API.LoginData> {
|
||||
/////////////////////////////////////////
|
||||
/////////////////////////////////////////
|
||||
// Setup
|
||||
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 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"
|
||||
|
||||
override var isActive: Boolean? = false
|
||||
override var willQueueSoon: Boolean? = false
|
||||
override var uploadJob: Job? = null
|
||||
|
||||
private var tempAuthFlow: AuthorizationCodeFlow? = null
|
||||
private var lastBackupJson: String? = null
|
||||
|
||||
/////////////////////////////////////////
|
||||
/////////////////////////////////////////
|
||||
// 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
|
||||
)
|
||||
|
||||
storeValue(K.TOKEN, googleTokenResponse)
|
||||
storeValue(K.IS_READY, true)
|
||||
updateApiActiveState()
|
||||
runDownloader(runNow = true, overwrite = true)
|
||||
|
||||
tempAuthFlow = null
|
||||
return true
|
||||
}
|
||||
|
||||
/////////////////////////////////////////
|
||||
/////////////////////////////////////////
|
||||
// InAppOAuth2APIManager implementation
|
||||
override suspend fun initialize() {
|
||||
updateApiActiveState()
|
||||
if (isActive != true) {
|
||||
return
|
||||
}
|
||||
|
||||
ioSafe {
|
||||
runDownloader(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun loginInfo(): AuthAPI.LoginInfo? {
|
||||
getCredentialsFromStore() ?: return null
|
||||
|
||||
return AuthAPI.LoginInfo(
|
||||
name = "google-account-$accountIndex",
|
||||
accountIndex = accountIndex
|
||||
)
|
||||
}
|
||||
|
||||
/////////////////////////////////////////
|
||||
/////////////////////////////////////////
|
||||
// InAppOAuth2API implementation
|
||||
override suspend fun getAuthorizationToken(
|
||||
activity: FragmentActivity,
|
||||
data: InAppOAuth2API.LoginData
|
||||
) {
|
||||
val credential = loginInfo()
|
||||
if (credential != null) {
|
||||
switchToNewAccount()
|
||||
}
|
||||
|
||||
storeValue(K.IS_READY, false)
|
||||
storeValue(K.LOGIN_DATA, data)
|
||||
|
||||
val authFlow = GAPI.createAuthFlow(data.clientId, data.secret)
|
||||
this.tempAuthFlow = authFlow
|
||||
|
||||
try {
|
||||
updateApiActiveState()
|
||||
registerAccount()
|
||||
|
||||
val url = authFlow.newAuthorizationUrl().setRedirectUri(data.redirectUrl).build()
|
||||
val customTabIntent = CustomTabsIntent.Builder().setShowTitle(true).build()
|
||||
customTabIntent.launchUrl(activity, Uri.parse(url))
|
||||
} catch (e: Exception) {
|
||||
switchToOldAccount()
|
||||
CommonActivity.showToast(
|
||||
activity,
|
||||
activity.getString(R.string.authenticated_user_fail).format(name)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getLatestLoginData(): InAppOAuth2API.LoginData? {
|
||||
return getValue(K.LOGIN_DATA)
|
||||
}
|
||||
|
||||
/////////////////////////////////////////
|
||||
/////////////////////////////////////////
|
||||
// BackupAPI implementation
|
||||
override fun isActive(): Boolean {
|
||||
return getValue<Boolean>(K.IS_READY) == true &&
|
||||
loginInfo() != null &&
|
||||
getDriveService() != null &&
|
||||
AcraApplication.context != null &&
|
||||
getLatestLoginData() != null
|
||||
}
|
||||
|
||||
override fun Context.createBackup(loginData: InAppOAuth2API.LoginData) {
|
||||
val drive = getDriveService() ?: return
|
||||
|
||||
val fileName = loginData.fileName
|
||||
val syncFileId = loginData.syncFileId
|
||||
val ioFile = java.io.File(AcraApplication.context?.cacheDir, fileName)
|
||||
lastBackupJson = getBackup().toJson()
|
||||
ioFile.writeText(lastBackupJson!!)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
override fun downloadSyncData(overwrite: Boolean) {
|
||||
val ctx = AcraApplication.context ?: return
|
||||
val drive = getDriveService() ?: return
|
||||
val loginData = getLatestLoginData() ?: return
|
||||
|
||||
val existingFileId = getOrFindExistingSyncFileId(drive, loginData)
|
||||
val existingFile = if (existingFileId != null) {
|
||||
try {
|
||||
drive.files().get(existingFileId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_KEY, "Could not find file for id $existingFileId", e)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (existingFile != null) {
|
||||
try {
|
||||
val inputStream: InputStream = existingFile.executeMediaAsInputStream()
|
||||
val content: String = inputStream.bufferedReader().use { it.readText() }
|
||||
Log.d(LOG_KEY, "downloadSyncData merging")
|
||||
ctx.mergeBackup(content, overwrite)
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_KEY, "download failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
// if failed
|
||||
Log.d(LOG_KEY, "downloadSyncData file not exists")
|
||||
uploadSyncData()
|
||||
}
|
||||
|
||||
private fun getOrFindExistingSyncFileId(
|
||||
drive: Drive,
|
||||
loginData: InAppOAuth2API.LoginData
|
||||
): 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
|
||||
}
|
||||
|
||||
override fun uploadSyncData() {
|
||||
val canUpload = getValue<Boolean>(K.IS_READY)
|
||||
if (canUpload != true) {
|
||||
Log.d(LOG_KEY, "uploadSyncData is not ready yet")
|
||||
return
|
||||
}
|
||||
|
||||
val ctx = AcraApplication.context
|
||||
val loginData = getLatestLoginData()
|
||||
|
||||
if (ctx == null) {
|
||||
Log.d(LOG_KEY, "uploadSyncData cannot run (ctx)")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (loginData == null) {
|
||||
Log.d(LOG_KEY, "uploadSyncData cannot run (loginData)")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(LOG_KEY, "uploadSyncData will run")
|
||||
ctx.createBackup(loginData)
|
||||
}
|
||||
|
||||
override fun shouldUpdate(changedKey: String, isSettings: Boolean): Boolean {
|
||||
val ctx = AcraApplication.context ?: return false
|
||||
|
||||
val newBackup = ctx.getBackup().toJson()
|
||||
return compareJson(lastBackupJson ?: "", newBackup).failed
|
||||
}
|
||||
|
||||
private fun getDriveService(): Drive? {
|
||||
val credential = getCredentialsFromStore() ?: return null
|
||||
|
||||
return Drive.Builder(
|
||||
GAPI.HTTP_TRANSPORT,
|
||||
GAPI.JSON_FACTORY,
|
||||
credential
|
||||
)
|
||||
.setApplicationName("cloudstreamapp-drive-sync")
|
||||
.build()
|
||||
}
|
||||
|
||||
/////////////////////////////////////////
|
||||
/////////////////////////////////////////
|
||||
// Internal
|
||||
private val continuousDownloader = Scheduler<Boolean>(
|
||||
BackupAPI.DOWNLOAD_THROTTLE.inWholeMilliseconds,
|
||||
onWork = { overwrite ->
|
||||
if (uploadJob?.isActive == true || willQueueSoon == true) {
|
||||
uploadJob!!.invokeOnCompletion {
|
||||
Log.d(LOG_KEY, "upload is running, reschedule download")
|
||||
runDownloader(false, overwrite == true)
|
||||
}
|
||||
} else {
|
||||
Log.d(LOG_KEY, "downloadSyncData will run")
|
||||
ioSafe {
|
||||
downloadSyncData(overwrite == true)
|
||||
}
|
||||
runDownloader()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
private fun runDownloader(runNow: Boolean = false, overwrite: Boolean = false) {
|
||||
if (runNow) {
|
||||
continuousDownloader.workNow(overwrite)
|
||||
} else {
|
||||
continuousDownloader.work(overwrite)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCredentialsFromStore(): Credential? {
|
||||
val loginDate = getLatestLoginData()
|
||||
val token = getValue<TokenResponse>(K.TOKEN)
|
||||
|
||||
val credential = if (loginDate != null && token != null) {
|
||||
GAPI.getCredentials(token, loginDate)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
if (credential.expirationTimeMilliseconds < Date().time) {
|
||||
val success = credential.refreshToken()
|
||||
|
||||
if (!success) {
|
||||
logOut()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return credential
|
||||
}
|
||||
|
||||
/////////////////////////////////////////
|
||||
/////////////////////////////////////////
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -32,6 +32,7 @@ import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia
|
|||
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterBackupRestoreEvent
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.bookmarksUpdatedEvent
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.mainPluginsLoadedEvent
|
||||
|
@ -500,6 +501,61 @@ class HomeFragment : Fragment() {
|
|||
fixGrid()
|
||||
}
|
||||
|
||||
fun bookmarksUpdated(_data : Boolean) {
|
||||
reloadStored()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
reloadStored()
|
||||
bookmarksUpdatedEvent += ::bookmarksUpdated
|
||||
afterPluginsLoadedEvent += ::afterPluginsLoaded
|
||||
mainPluginsLoadedEvent += ::afterMainPluginsLoaded
|
||||
afterBackupRestoreEvent += ::reloadStored
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
bookmarksUpdatedEvent -= ::bookmarksUpdated
|
||||
afterPluginsLoadedEvent -= ::afterPluginsLoaded
|
||||
mainPluginsLoadedEvent -= ::afterMainPluginsLoaded
|
||||
afterBackupRestoreEvent -= ::reloadStored
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
private fun reloadStored(unused: Unit = Unit) {
|
||||
homeViewModel.loadResumeWatching()
|
||||
val list = EnumSet.noneOf(WatchType::class.java)
|
||||
getKey<IntArray>(HOME_BOOKMARK_VALUE_LIST)?.map { WatchType.fromInternalId(it) }?.let {
|
||||
list.addAll(it)
|
||||
}
|
||||
homeViewModel.loadStoredData(list)
|
||||
}
|
||||
|
||||
private fun afterMainPluginsLoaded(unused: Boolean = false) {
|
||||
loadHomePage(false)
|
||||
}
|
||||
|
||||
private fun afterPluginsLoaded(forceReload: Boolean) {
|
||||
loadHomePage(forceReload)
|
||||
}
|
||||
|
||||
private fun loadHomePage(forceReload: Boolean) {
|
||||
val apiName = context?.getKey<String>(USER_SELECTED_HOMEPAGE_API)
|
||||
|
||||
if (homeViewModel.apiName.value != apiName || apiName == null || forceReload) {
|
||||
//println("Caught home: " + homeViewModel.apiName.value + " at " + apiName)
|
||||
homeViewModel.loadAndCancel(apiName, forceReload)
|
||||
}
|
||||
}
|
||||
|
||||
private fun homeHandleSearch(callback: SearchClickCallback) {
|
||||
if (callback.action == SEARCH_ACTION_FOCUSED) {
|
||||
//focusCallback(callback.card)
|
||||
} else {
|
||||
handleSearchClickCallback(activity, callback)
|
||||
}
|
||||
}
|
||||
|
||||
private var currentApiName: String? = null
|
||||
private var toggleRandomButton = false
|
||||
|
||||
|
|
|
@ -6,6 +6,8 @@ import android.content.res.Configuration
|
|||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
@ -21,11 +23,13 @@ import com.lagradost.cloudstream3.APIHolder.allProviders
|
|||
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.MainActivity
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.debugAssert
|
||||
import com.lagradost.cloudstream3.mvvm.observe
|
||||
import com.lagradost.cloudstream3.syncproviders.BackupAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
|
||||
|
@ -38,6 +42,7 @@ import com.lagradost.cloudstream3.utils.AppUtils.reduceDragSensitivity
|
|||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
||||
import org.checkerframework.framework.qual.Unused
|
||||
import kotlin.math.abs
|
||||
|
||||
const val LIBRARY_FOLDER = "library_folder"
|
||||
|
@ -78,6 +83,7 @@ class LibraryFragment : Fragment() {
|
|||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
MainActivity.afterBackupRestoreEvent += ::onNewSyncData
|
||||
val localBinding = FragmentLibraryBinding.inflate(inflater, container, false)
|
||||
binding = localBinding
|
||||
return localBinding.root
|
||||
|
@ -90,6 +96,11 @@ class LibraryFragment : Fragment() {
|
|||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
MainActivity.afterBackupRestoreEvent -= ::onNewSyncData
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
binding?.viewpager?.currentItem?.let { currentItem ->
|
||||
outState.putInt(VIEWPAGER_ITEM_KEY, currentItem)
|
||||
|
@ -420,6 +431,21 @@ class LibraryFragment : Fragment() {
|
|||
(binding?.viewpager?.adapter as? ViewpagerAdapter)?.rebind()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
class MenuSearchView(context: Context) : SearchView(context) {
|
||||
|
|
|
@ -31,6 +31,7 @@ import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding
|
|||
import com.lagradost.cloudstream3.mvvm.*
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||
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.CustomDecoder.Companion.updateForcedEncoding
|
||||
import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType
|
||||
|
@ -42,6 +43,7 @@ import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAu
|
|||
import com.lagradost.cloudstream3.utils.*
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
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.SubtitleHelper.fromTwoLettersToLanguage
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.languages
|
||||
|
@ -713,6 +715,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
|
||||
binding.subtitlesClickSettings.setOnClickListener {
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
.attachBackupListener(ctx.getSyncPrefs()).self
|
||||
|
||||
val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list)
|
||||
val prefValues = ctx.resources.getStringArray(R.array.subtitles_encoding_values)
|
||||
|
|
|
@ -2,25 +2,21 @@ package com.lagradost.cloudstream3.ui.settings
|
|||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.View.*
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.AccountManagmentBinding
|
||||
import com.lagradost.cloudstream3.databinding.AccountSwitchBinding
|
||||
import com.lagradost.cloudstream3.databinding.AddAccountInputBinding
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||
import com.lagradost.cloudstream3.syncproviders.*
|
||||
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.openSubtitlesApi
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi
|
||||
|
@ -31,7 +27,8 @@ import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref
|
|||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
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.UIHelper.dismissSafe
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setImage
|
||||
|
@ -260,6 +257,7 @@ class SettingsAccount : PreferenceFragmentCompat() {
|
|||
R.string.anilist_key to aniListApi,
|
||||
R.string.simkl_key to simklApi,
|
||||
R.string.opensubtitles_key to openSubtitlesApi,
|
||||
R.string.gdrive_key to googleDriveApi
|
||||
)
|
||||
|
||||
for ((key, api) in syncApis) {
|
||||
|
|
|
@ -25,10 +25,12 @@ import com.lagradost.cloudstream3.databinding.AddSiteInputBinding
|
|||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
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.settings.SettingsFragment.Companion.getPref
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
|
||||
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.showDialog
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
|
||||
|
@ -146,13 +148,19 @@ class SettingsGeneral : PreferenceFragmentCompat() {
|
|||
// Stores the real URI using download_path_key
|
||||
// Important that the URI is stored instead of filepath due to permissions.
|
||||
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
|
||||
// File path here is purely for cosmetic purposes in settings
|
||||
(filePath ?: uri.toString()).let {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -160,6 +168,7 @@ class SettingsGeneral : PreferenceFragmentCompat() {
|
|||
hideKeyboard()
|
||||
setPreferencesFromResource(R.xml.settins_general, rootKey)
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
.attachBackupListener(requireContext().getSyncPrefs()).self
|
||||
|
||||
fun getCurrent(): MutableList<CustomSite> {
|
||||
return getKey<Array<CustomSite>>(USER_PROVIDER_API)?.toMutableList()
|
||||
|
|
|
@ -7,12 +7,14 @@ import androidx.preference.PreferenceFragmentCompat
|
|||
import androidx.preference.PreferenceManager
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getFolderSize
|
||||
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.setUpToolbar
|
||||
import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment
|
||||
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.SingleSelectionHelper.showBottomDialog
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
||||
|
@ -28,6 +30,7 @@ class SettingsPlayer : PreferenceFragmentCompat() {
|
|||
hideKeyboard()
|
||||
setPreferencesFromResource(R.xml.settings_player, rootKey)
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
.attachBackupListener(requireContext().getSyncPrefs()).self
|
||||
|
||||
getPref(R.string.video_buffer_length_key)?.setOnPreferenceClickListener {
|
||||
val prefNames = resources.getStringArray(R.array.video_buffer_length_names)
|
||||
|
|
|
@ -6,19 +6,25 @@ import androidx.navigation.NavOptions
|
|||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.APIHolder
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
|
||||
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.settings.SettingsFragment.Companion.getPref
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
||||
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.SubtitleHelper
|
||||
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() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
@ -31,6 +37,7 @@ class SettingsProviders : PreferenceFragmentCompat() {
|
|||
hideKeyboard()
|
||||
setPreferencesFromResource(R.xml.settings_providers, rootKey)
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
.attachBackupListener(requireContext().getSyncPrefs()).self
|
||||
|
||||
getPref(R.string.display_sub_key)?.setOnPreferenceClickListener {
|
||||
activity?.getApiDubstatusSettings()?.let { current ->
|
||||
|
|
|
@ -8,11 +8,13 @@ import androidx.preference.PreferenceManager
|
|||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.SearchQuality
|
||||
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.settings.SettingsFragment.Companion.getPref
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
|
||||
|
@ -29,6 +31,7 @@ class SettingsUI : PreferenceFragmentCompat() {
|
|||
hideKeyboard()
|
||||
setPreferencesFromResource(R.xml.settins_ui, rootKey)
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
.attachBackupListener(requireContext().getSyncPrefs()).self
|
||||
|
||||
getPref(R.string.poster_ui_key)?.setOnPreferenceClickListener {
|
||||
val prefNames = resources.getStringArray(R.array.poster_ui_options)
|
||||
|
|
|
@ -19,12 +19,14 @@ import com.lagradost.cloudstream3.app
|
|||
import com.lagradost.cloudstream3.databinding.LogcatBinding
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.network.initClient
|
||||
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||
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.setUpToolbar
|
||||
import com.lagradost.cloudstream3.utils.BackupUtils.backup
|
||||
import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt
|
||||
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.SingleSelectionHelper.showBottomDialog
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||
|
@ -134,6 +136,7 @@ class SettingsUpdates : PreferenceFragmentCompat() {
|
|||
|
||||
getPref(R.string.apk_installer_key)?.setOnPreferenceClickListener {
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context)
|
||||
.attachBackupListener(it.context.getSyncPrefs()).self
|
||||
|
||||
val prefNames = resources.getStringArray(R.array.apk_installer_pref)
|
||||
val prefValues = resources.getIntArray(R.array.apk_installer_values)
|
||||
|
|
|
@ -0,0 +1,136 @@
|
|||
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 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(
|
||||
private val api: AuthAPI,
|
||||
private val activity: FragmentActivity?,
|
||||
private val themeResId: Int,
|
||||
private val layoutResId: Int,
|
||||
) {
|
||||
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(layoutResId)
|
||||
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,100 @@
|
|||
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.mvvm.logError
|
||||
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||
import kotlinx.android.synthetic.main.add_account_input.*
|
||||
|
||||
class InAppAuthDialogBuilder(
|
||||
private val api: InAppAuthAPI,
|
||||
private val activity: FragmentActivity?,
|
||||
) : DialogBuilder(
|
||||
api,
|
||||
activity,
|
||||
R.style.AlertDialogCustom,
|
||||
R.layout.add_account_input,
|
||||
) {
|
||||
|
||||
override fun onLogin(dialog: AlertDialog): Unit = with(dialog) {
|
||||
if (activity == null) throw IllegalStateException("Login should be called after validation")
|
||||
|
||||
val loginData = InAppAuthAPI.LoginData(
|
||||
username = if (api.requiresUsername) login_username_input?.text?.toString() else null,
|
||||
password = if (api.requiresPassword) login_password_input?.text?.toString() else null,
|
||||
email = if (api.requiresEmail) login_email_input?.text?.toString() else null,
|
||||
server = if (api.requiresServer) login_server_input?.text?.toString() else null,
|
||||
)
|
||||
|
||||
ioSafe {
|
||||
val isSuccessful = try {
|
||||
api.login(loginData)
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
false
|
||||
}
|
||||
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(dialog) {
|
||||
CommonDialogItems(dialog, text1, apply_btt, cancel_btt, create_account,null)
|
||||
}
|
||||
|
||||
override fun getVisibilityMap(dialog: AlertDialog): Map<View, Boolean> = with(dialog) {
|
||||
mapOf(
|
||||
login_email_input to api.requiresEmail,
|
||||
login_password_input to api.requiresPassword,
|
||||
login_server_input to api.requiresServer,
|
||||
login_username_input to api.requiresUsername
|
||||
)
|
||||
}
|
||||
|
||||
override fun setupItems(dialog: AlertDialog): Unit = with(dialog) {
|
||||
login_email_input?.isVisible = api.requiresEmail
|
||||
login_password_input?.isVisible = api.requiresPassword
|
||||
login_server_input?.isVisible = api.requiresServer
|
||||
login_username_input?.isVisible = api.requiresUsername
|
||||
|
||||
create_account?.isGone = api.createAccountUrl.isNullOrBlank()
|
||||
create_account?.setOnClickListener {
|
||||
AcraApplication.openBrowser(
|
||||
api.createAccountUrl ?: return@setOnClickListener, activity
|
||||
)
|
||||
|
||||
dismissSafe()
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleStoresPasswordInPlainText(dialog: AlertDialog): Unit = with(dialog) {
|
||||
if (!api.storesPasswordInPlainText) return
|
||||
|
||||
api.getLatestLoginData()?.let { data ->
|
||||
login_email_input?.setText(data.email ?: "")
|
||||
login_server_input?.setText(data.server ?: "")
|
||||
login_username_input?.setText(data.username ?: "")
|
||||
login_password_input?.setText(data.password ?: "")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
package com.lagradost.cloudstream3.ui.settings.helpers.settings.account
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.syncproviders.InAppOAuth2API
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||
import kotlinx.android.synthetic.main.add_account_input_oauth.apply_btt
|
||||
import kotlinx.android.synthetic.main.add_account_input_oauth.cancel_btt
|
||||
import kotlinx.android.synthetic.main.add_account_input_oauth.info_button
|
||||
import kotlinx.android.synthetic.main.add_account_input_oauth.login_client_id
|
||||
import kotlinx.android.synthetic.main.add_account_input_oauth.login_client_secret
|
||||
import kotlinx.android.synthetic.main.add_account_input_oauth.login_file_name
|
||||
import kotlinx.android.synthetic.main.add_account_input_oauth.text1
|
||||
|
||||
|
||||
class InAppOAuth2DialogBuilder(
|
||||
private val api: InAppOAuth2API,
|
||||
private val activity: FragmentActivity?,
|
||||
) : DialogBuilder(api, activity, R.style.AlertDialogCustom, R.layout.add_account_input_oauth) {
|
||||
override fun getCommonItems(dialog: AlertDialog) = with(dialog) {
|
||||
CommonDialogItems(dialog, text1, apply_btt, cancel_btt, null, info_button)
|
||||
}
|
||||
|
||||
override fun getVisibilityMap(dialog: AlertDialog): Map<View, Boolean> = with(dialog) {
|
||||
mapOf(
|
||||
login_file_name to api.requiresFilename,
|
||||
login_client_id to api.requiresClientId,
|
||||
login_client_secret to api.requiresSecret,
|
||||
)
|
||||
}
|
||||
|
||||
override fun setupItems(dialog: AlertDialog): Unit = with(dialog) {
|
||||
login_file_name?.isVisible = api.requiresFilename
|
||||
login_client_id?.isVisible = api.requiresClientId
|
||||
login_client_secret?.isVisible = api.requiresSecret
|
||||
|
||||
info_button?.isGone = api.infoUrl.isNullOrBlank()
|
||||
info_button?.setOnClickListener {
|
||||
val customTabIntent = CustomTabsIntent.Builder().setShowTitle(true).build()
|
||||
customTabIntent.launchUrl(context, Uri.parse(api.infoUrl))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onLogin(dialog: AlertDialog): Unit = with(activity) {
|
||||
if (this == null) throw IllegalStateException("Login should be called after validation")
|
||||
|
||||
val clientId = dialog.login_client_id.text.toString().ifBlank {
|
||||
getString(R.string.debug_gdrive_clientId)
|
||||
}
|
||||
val clientSecret = dialog.login_client_secret.text.toString().ifBlank {
|
||||
getString(R.string.debug_gdrive_secret)
|
||||
}
|
||||
val syncFileName = dialog.login_file_name.text.toString().trim().ifBlank {
|
||||
api.defaultFilenameValue
|
||||
}
|
||||
val redirectUrl = dialog.login_file_name.text.toString().trim().ifBlank {
|
||||
api.defaultRedirectUrl
|
||||
}
|
||||
|
||||
ioSafe {
|
||||
api.getAuthorizationToken(
|
||||
this@with,
|
||||
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.mvvm.normalSafeApiCall
|
||||
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.getCurrentLocale
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||
|
||||
|
@ -51,6 +53,7 @@ class SetupFragmentLanguage : Fragment() {
|
|||
|
||||
val ctx = context ?: return@normalSafeApiCall
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
.attachBackupListener(getSyncPrefs()).self
|
||||
|
||||
val arrayAdapter =
|
||||
ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice)
|
||||
|
|
|
@ -13,6 +13,8 @@ import com.lagradost.cloudstream3.R
|
|||
import com.lagradost.cloudstream3.databinding.FragmentSetupLayoutBinding
|
||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
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
|
||||
|
||||
|
||||
|
@ -43,6 +45,7 @@ class SetupFragmentLayout : Fragment() {
|
|||
val ctx = context ?: return@normalSafeApiCall
|
||||
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
.attachBackupListener(getSyncPrefs()).self
|
||||
|
||||
val prefNames = resources.getStringArray(R.array.app_layout)
|
||||
val prefValues = resources.getIntArray(R.array.app_layout_values)
|
||||
|
|
|
@ -17,6 +17,10 @@ import com.lagradost.cloudstream3.databinding.FragmentSetupMediaBinding
|
|||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||
import com.lagradost.cloudstream3.utils.DataStore.removeKey
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||
|
||||
|
||||
class SetupFragmentMedia : Fragment() {
|
||||
|
@ -45,6 +49,7 @@ class SetupFragmentMedia : Fragment() {
|
|||
|
||||
val ctx = context ?: return@normalSafeApiCall
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
.attachBackupListener(getSyncPrefs()).self
|
||||
|
||||
val arrayAdapter =
|
||||
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.utils.SubtitleHelper
|
||||
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() {
|
||||
var binding: FragmentSetupProviderLanguagesBinding? = null
|
||||
|
@ -46,6 +48,7 @@ class SetupFragmentProviderLanguage : Fragment() {
|
|||
val ctx = context ?: return@normalSafeApiCall
|
||||
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
.attachBackupListener(getSyncPrefs()).self
|
||||
|
||||
val arrayAdapter =
|
||||
ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice)
|
||||
|
|
|
@ -28,7 +28,9 @@ import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent
|
|||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.SubtitleSettingsBinding
|
||||
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||
import com.lagradost.cloudstream3.utils.DataStore.setKey
|
||||
import com.lagradost.cloudstream3.utils.Event
|
||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
||||
|
@ -455,6 +457,7 @@ class SubtitlesFragment : Fragment() {
|
|||
subtitlesFilterSubLang.setOnCheckedChangeListener { _, b ->
|
||||
context?.let { ctx ->
|
||||
PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
.attachBackupListener(ctx.getSyncPrefs()).self
|
||||
.edit()
|
||||
.putBoolean(getString(R.string.filter_sub_lang_key), b)
|
||||
.apply()
|
||||
|
|
|
@ -3,6 +3,9 @@ package com.lagradost.cloudstream3.utils
|
|||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
|
@ -11,10 +14,13 @@ import androidx.fragment.app.FragmentActivity
|
|||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterBackupRestoreEvent
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.plugins.PLUGINS_KEY
|
||||
import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL
|
||||
import com.lagradost.cloudstream3.syncproviders.BackupAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.InAppOAuth2APIManager
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_TOKEN_KEY
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_UNIXTIME_KEY
|
||||
|
@ -30,7 +36,9 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
|||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs
|
||||
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.removeKeyRaw
|
||||
import com.lagradost.cloudstream3.utils.DataStore.setKeyRaw
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
|
||||
|
@ -43,11 +51,17 @@ import java.text.SimpleDateFormat
|
|||
import java.util.*
|
||||
|
||||
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
|
||||
* */
|
||||
private val nonTransferableKeys = listOf(
|
||||
val nonTransferableKeys = listOf(
|
||||
// When sharing backup we do not want to transfer what is essentially the password
|
||||
ANILIST_TOKEN_KEY,
|
||||
ANILIST_CACHED_LIST,
|
||||
|
@ -58,6 +72,8 @@ object BackupUtils {
|
|||
MAL_CACHED_LIST,
|
||||
MAL_UNIXTIME_KEY,
|
||||
MAL_USER_KEY,
|
||||
InAppOAuth2APIManager.K.TOKEN.value,
|
||||
InAppOAuth2APIManager.K.IS_READY.value,
|
||||
|
||||
// The plugins themselves are not backed up
|
||||
PLUGINS_KEY,
|
||||
|
@ -69,12 +85,22 @@ object BackupUtils {
|
|||
|
||||
/** false if blacklisted key */
|
||||
private fun String.isTransferable(): Boolean {
|
||||
return !nonTransferableKeys.contains(this)
|
||||
return !nonTransferableKeys.any { this.contains(it) }
|
||||
}
|
||||
|
||||
private var restoreFileSelector: ActivityResultLauncher<Array<String>>? = null
|
||||
|
||||
// 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(
|
||||
@JsonProperty("_Bool") val _Bool: Map<String, Boolean>?,
|
||||
@JsonProperty("_Int") val _Int: Map<String, Int>?,
|
||||
|
@ -82,18 +108,62 @@ object BackupUtils {
|
|||
@JsonProperty("_Float") val _Float: Map<String, Float>?,
|
||||
@JsonProperty("_Long") val _Long: Map<String, Long>?,
|
||||
@JsonProperty("_StringSet") val _StringSet: Map<String, Set<String>?>?,
|
||||
)
|
||||
) {
|
||||
constructor() : this(
|
||||
mapOf(),
|
||||
mapOf(),
|
||||
mapOf(),
|
||||
mapOf(),
|
||||
mapOf(),
|
||||
mapOf(),
|
||||
)
|
||||
}
|
||||
|
||||
data class BackupFile(
|
||||
@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")
|
||||
fun Context.getBackup(): BackupFile {
|
||||
val syncDataPrefs = getSyncPrefs().all.filter { it.key.isTransferable() }
|
||||
val allData = getSharedPrefs().all.filter { it.key.isTransferable() }
|
||||
val allSettings = 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(
|
||||
allData.filter { it.value is Boolean } as? Map<String, Boolean>,
|
||||
allData.filter { it.value is Int } as? Map<String, Int>,
|
||||
|
@ -114,33 +184,45 @@ object BackupUtils {
|
|||
|
||||
return BackupFile(
|
||||
allDataSorted,
|
||||
allSettingsSorted
|
||||
allSettingsSorted,
|
||||
syncData
|
||||
)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun Context.restore(backupFile: BackupFile, restoreKeys: Set<String>? = null) = restore(
|
||||
backupFile,
|
||||
restoreKeys,
|
||||
RestoreSource.SYNC,
|
||||
RestoreSource.DATA,
|
||||
RestoreSource.SETTINGS
|
||||
)
|
||||
|
||||
@WorkerThread
|
||||
fun Context.restore(
|
||||
backupFile: BackupFile,
|
||||
restoreSettings: Boolean,
|
||||
restoreDataStore: Boolean
|
||||
restoreKeys: Set<String>? = null,
|
||||
vararg restoreSources: RestoreSource
|
||||
) {
|
||||
if (restoreSettings) {
|
||||
restoreMap(backupFile.settings._Bool, true)
|
||||
restoreMap(backupFile.settings._Int, true)
|
||||
restoreMap(backupFile.settings._String, true)
|
||||
restoreMap(backupFile.settings._Float, true)
|
||||
restoreMap(backupFile.settings._Long, true)
|
||||
restoreMap(backupFile.settings._StringSet, true)
|
||||
Log.d(BackupAPI.LOG_KEY, "will restore keys = $restoreKeys")
|
||||
|
||||
for (restoreSource in restoreSources) {
|
||||
val restoreData = RestoreMapData()
|
||||
|
||||
restoreData.addAll(backupFile.restore(this, restoreSource, restoreKeys))
|
||||
|
||||
// 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) {
|
||||
restoreMap(backupFile.datastore._Bool)
|
||||
restoreMap(backupFile.datastore._Int)
|
||||
restoreMap(backupFile.datastore._String)
|
||||
restoreMap(backupFile.datastore._Float)
|
||||
restoreMap(backupFile.datastore._Long)
|
||||
restoreMap(backupFile.datastore._StringSet)
|
||||
}
|
||||
Log.d(BackupAPI.LOG_KEY, "restore on ui event fired")
|
||||
afterBackupRestoreEvent.invoke(Unit)
|
||||
}
|
||||
|
||||
@SuppressLint("SimpleDateFormat")
|
||||
|
@ -195,14 +277,7 @@ object BackupUtils {
|
|||
val input = activity.contentResolver.openInputStream(uri)
|
||||
?: return@ioSafe
|
||||
|
||||
val restoredValue =
|
||||
mapper.readValue<BackupFile>(input)
|
||||
|
||||
activity.restore(
|
||||
restoredValue,
|
||||
restoreSettings = true,
|
||||
restoreDataStore = true
|
||||
)
|
||||
activity.restore(mapper.readValue(input))
|
||||
activity.runOnUiThread { activity.recreate() }
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
|
@ -242,10 +317,54 @@ object BackupUtils {
|
|||
|
||||
private fun <T> Context.restoreMap(
|
||||
map: Map<String, T>?,
|
||||
isEditingAppSettings: Boolean = false
|
||||
) {
|
||||
map?.filter { it.key.isTransferable() }?.forEach {
|
||||
setKeyRaw(it.key, it.value, isEditingAppSettings)
|
||||
restoreSource: RestoreSource,
|
||||
restoreKeys: Set<String>? = null
|
||||
): RestoreMapData {
|
||||
val restoreOnlyThese = mutableSetOf<String>()
|
||||
val successfulRestore = mutableSetOf<String>()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
@ -12,6 +12,7 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.setKeyClass
|
|||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KProperty
|
||||
import com.lagradost.cloudstream3.syncproviders.BackupAPI
|
||||
|
||||
const val DOWNLOAD_HEADER_CACHE = "download_header_cache"
|
||||
|
||||
|
@ -22,6 +23,7 @@ const val USER_SELECTED_HOMEPAGE_API = "home_api_used"
|
|||
const val USER_PROVIDER_API = "user_custom_sites"
|
||||
|
||||
const val PREFERENCES_NAME = "rebuild_preference"
|
||||
const val SYNC_PREFERENCES_NAME = "rebuild_sync_preference"
|
||||
|
||||
// TODO degelgate by value for get & set
|
||||
|
||||
|
@ -52,12 +54,24 @@ class PreferenceDelegate<T : Any>(
|
|||
|
||||
object DataStore {
|
||||
val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.configure(DeserializationFeature.USE_LONG_FOR_INTS, true)
|
||||
.build()
|
||||
|
||||
private val backupScheduler = Scheduler.createBackupScheduler()
|
||||
|
||||
private fun getPreferences(context: Context): SharedPreferences {
|
||||
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 {
|
||||
return getPreferences(this)
|
||||
}
|
||||
|
@ -65,11 +79,14 @@ object DataStore {
|
|||
fun getFolderName(folder: String, path: String): String {
|
||||
return "${folder}/${path}"
|
||||
}
|
||||
|
||||
fun <T> Context.setKeyRaw(path: String, value: T, isEditingAppSettings: Boolean = false) {
|
||||
fun <T> Context.setKeyRaw(path: String, value: T, restoreSource: BackupUtils.RestoreSource) {
|
||||
try {
|
||||
val editor: SharedPreferences.Editor =
|
||||
if (isEditingAppSettings) getDefaultSharedPrefs().edit() else getSharedPrefs().edit()
|
||||
val editor = when (restoreSource) {
|
||||
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)
|
||||
|
@ -83,6 +100,17 @@ object DataStore {
|
|||
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 {
|
||||
return PreferenceManager.getDefaultSharedPreferences(this)
|
||||
|
@ -109,9 +137,21 @@ object DataStore {
|
|||
try {
|
||||
val prefs = getSharedPrefs()
|
||||
if (prefs.contains(path)) {
|
||||
val oldValueExists = prefs.getString(path, null) != null
|
||||
|
||||
val editor: SharedPreferences.Editor = prefs.edit()
|
||||
editor.remove(path)
|
||||
editor.apply()
|
||||
|
||||
backupScheduler.work(
|
||||
BackupAPI.PreferencesSchedulerData(
|
||||
getSyncPrefs(),
|
||||
path,
|
||||
oldValueExists,
|
||||
false,
|
||||
BackupUtils.RestoreSource.DATA
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
|
@ -128,9 +168,23 @@ object DataStore {
|
|||
|
||||
fun <T> Context.setKey(path: String, value: T) {
|
||||
try {
|
||||
val editor: SharedPreferences.Editor = getSharedPrefs().edit()
|
||||
editor.putString(path, mapper.writeValueAsString(value))
|
||||
val prefs = getSharedPrefs()
|
||||
val oldValue = prefs.getString(path, null)
|
||||
val newValue = mapper.writeValueAsString(value)
|
||||
|
||||
val editor: SharedPreferences.Editor = prefs.edit()
|
||||
editor.putString(path, newValue)
|
||||
editor.apply()
|
||||
|
||||
backupScheduler.work(
|
||||
BackupAPI.PreferencesSchedulerData(
|
||||
getSyncPrefs(),
|
||||
path,
|
||||
oldValue,
|
||||
newValue,
|
||||
BackupUtils.RestoreSource.DATA
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
|
@ -149,6 +203,7 @@ object DataStore {
|
|||
setKey(getFolderName(folder, path), value)
|
||||
}
|
||||
|
||||
|
||||
inline fun <reified T : Any> String.toKotlinObject(): T {
|
||||
return mapper.readValue(this, T::class.java)
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.app.Activity
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
|
@ -14,17 +15,18 @@ import com.fasterxml.jackson.annotation.JsonProperty
|
|||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
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.setDefaultFocus
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okio.BufferedSink
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import java.io.File
|
||||
import android.text.TextUtils
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
|
||||
|
@ -74,6 +76,7 @@ class InAppUpdater {
|
|||
private suspend fun Activity.getAppUpdate(): Update {
|
||||
return try {
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
.attachBackupListener(getSyncPrefs()).self
|
||||
if (settingsManager.getBoolean(
|
||||
getString(R.string.prerelease_update_key),
|
||||
resources.getBoolean(R.bool.is_prerelease)
|
||||
|
@ -255,7 +258,9 @@ class InAppUpdater {
|
|||
* @param checkAutoUpdate if the update check was launched automatically
|
||||
**/
|
||||
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(
|
||||
getString(R.string.auto_update_key),
|
||||
|
@ -265,7 +270,8 @@ class InAppUpdater {
|
|||
val update = getAppUpdate()
|
||||
if (
|
||||
update.shouldUpdate &&
|
||||
update.updateURL != null) {
|
||||
update.updateURL != null
|
||||
) {
|
||||
|
||||
// Check if update should be skipped
|
||||
val updateNodeId =
|
||||
|
|
159
app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt
Normal file
159
app/src/main/java/com/lagradost/cloudstream3/utils/Scheduler.kt
Normal file
|
@ -0,0 +1,159 @@
|
|||
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.ui.home.HOME_BOOKMARK_VALUE_LIST
|
||||
import com.lagradost.cloudstream3.ui.player.PLAYBACK_SPEED_KEY
|
||||
import com.lagradost.cloudstream3.ui.player.RESIZE_MODE_KEY
|
||||
import com.lagradost.cloudstream3.utils.BackupUtils.nonTransferableKeys
|
||||
|
||||
class Scheduler<INPUT>(
|
||||
private val throttleTimeMs: Long,
|
||||
private val onWork: (INPUT?) -> Unit,
|
||||
private val beforeWork: ((INPUT?) -> Unit)? = null,
|
||||
private val canWork: ((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,
|
||||
PLAYBACK_SPEED_KEY,
|
||||
HOME_BOOKMARK_VALUE_LIST,
|
||||
RESIZE_MODE_KEY
|
||||
)
|
||||
|
||||
fun createBackupScheduler() = Scheduler<BackupAPI.PreferencesSchedulerData<*>>(
|
||||
BackupAPI.UPLOAD_THROTTLE.inWholeMilliseconds,
|
||||
onWork = { input ->
|
||||
if (input == null) {
|
||||
throw IllegalStateException()
|
||||
}
|
||||
|
||||
AccountManager.BackupApis.forEach {
|
||||
it.addToQueue(
|
||||
input.storeKey,
|
||||
input.source == BackupUtils.RestoreSource.SETTINGS
|
||||
)
|
||||
}
|
||||
},
|
||||
beforeWork = {
|
||||
AccountManager.BackupApis.filter {
|
||||
it.isActive == true
|
||||
}.forEach {
|
||||
it.willQueueSoon = true
|
||||
}
|
||||
},
|
||||
canWork = { input ->
|
||||
if (input == null) {
|
||||
throw IllegalStateException()
|
||||
}
|
||||
|
||||
val hasSomeActiveManagers = AccountManager.BackupApis.any { it.isActive == true }
|
||||
if (!hasSomeActiveManagers) {
|
||||
return@Scheduler false
|
||||
}
|
||||
|
||||
val hasInvalidKey = invalidUploadTriggerKeys.contains(input.storeKey)
|
||||
if (hasInvalidKey) {
|
||||
return@Scheduler false
|
||||
}
|
||||
|
||||
val valueDidNotChange = input.oldValue == input.newValue
|
||||
if (valueDidNotChange) {
|
||||
return@Scheduler false
|
||||
}
|
||||
|
||||
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 ->
|
||||
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
|
||||
|
||||
fun work(input: INPUT? = null): 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
|
||||
}
|
||||
|
||||
fun workNow(input: INPUT? = null): 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
|
||||
}
|
||||
}
|
||||
|
||||
private fun throttle(input: INPUT?) {
|
||||
stop()
|
||||
|
||||
runnable = Runnable {
|
||||
Log.d(BackupAPI.LOG_KEY, "[$id] schedule success")
|
||||
onWork(input)
|
||||
}
|
||||
handler.postDelayed(runnable!!, throttleTimeMs)
|
||||
}
|
||||
}
|
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/sort_cancel" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
|
@ -448,10 +448,13 @@
|
|||
<string name="bottom_title_settings">Poster title location</string>
|
||||
<string name="bottom_title_settings_des">Put the title under the poster</string>
|
||||
<!-- 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="simkl_key" translatable="false">simkl_key</string>
|
||||
<string name="mal_key" translatable="false">mal_key</string>
|
||||
<string name="opensubtitles_key" translatable="false">opensubtitles_key</string>
|
||||
<string name="gdrive_key" translatable="false">gdrive_key</string>
|
||||
<string name="nginx_key" translatable="false">nginx_key</string>
|
||||
<string name="example_password">password123</string>
|
||||
<string name="example_username">MyCoolUsername</string>
|
||||
|
@ -460,6 +463,9 @@
|
|||
<string name="example_site_name">MyCoolSite</string>
|
||||
<string name="example_site_url">example.com</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="anilist_account_settings" translatable="false">AniList</string>
|
||||
|
@ -684,8 +690,11 @@
|
|||
<string name="qualities">Qualities</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="tv_no_focus_tag" translatable="false">tv_no_focus_tag</string>
|
||||
<string name="already_voted">You have already voted</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>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -1,24 +1,26 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<Preference
|
||||
android:icon="@drawable/mal_logo"
|
||||
android:key="@string/mal_key" />
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<PreferenceCategory android:title="@string/settings_category_plugins">
|
||||
<Preference
|
||||
android:key="@string/mal_key"
|
||||
android:icon="@drawable/mal_logo" />
|
||||
|
||||
<Preference
|
||||
android:icon="@drawable/ic_anilist_icon"
|
||||
android:key="@string/anilist_key" />
|
||||
|
||||
<Preference
|
||||
android:icon="@drawable/simkl_logo"
|
||||
android:key="@string/simkl_key" />
|
||||
|
||||
<Preference
|
||||
android:icon="@drawable/open_subtitles_icon"
|
||||
android:key="@string/opensubtitles_key" />
|
||||
<!-- <Preference-->
|
||||
<!-- android:key="@string/nginx_key"-->
|
||||
<!-- android:icon="@drawable/nginx" />-->
|
||||
<Preference
|
||||
android:key="@string/anilist_key"
|
||||
android:icon="@drawable/ic_anilist_icon" />
|
||||
<Preference
|
||||
android:key="@string/opensubtitles_key"
|
||||
android:icon="@drawable/open_subtitles_icon" />
|
||||
</PreferenceCategory>
|
||||
<PreferenceCategory android:title="@string/settings_category_remote_sync">
|
||||
<Preference
|
||||
android:key="@string/gdrive_key"
|
||||
android:icon="@drawable/ic_baseline_add_to_drive_24" />
|
||||
</PreferenceCategory>
|
||||
<!-- <Preference-->
|
||||
<!-- android:key="@string/nginx_key"-->
|
||||
<!-- android:icon="@drawable/nginx" />-->
|
||||
|
||||
<!-- <Preference-->
|
||||
<!-- android:title="@string/nginx_info_title"-->
|
||||
|
|
Loading…
Reference in a new issue