Merge branch 'feature/remote-sync' into sync

This commit is contained in:
CranberrySoup 2023-09-22 11:30:24 +00:00 committed by GitHub
commit 361a5bb62a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1825 additions and 87 deletions

1
.gitignore vendored
View file

@ -14,3 +14,4 @@
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
/.idea

View file

@ -2,6 +2,8 @@ import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import org.jetbrains.dokka.gradle.DokkaTask import org.jetbrains.dokka.gradle.DokkaTask
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.net.URL import java.net.URL
import java.util.Properties
import java.io.FileInputStream
plugins { plugins {
id("com.android.application") id("com.android.application")
@ -23,6 +25,15 @@ fun String.execute() = ByteArrayOutputStream().use { baot ->
else null else null
} }
val localProperties = Properties()
try {
localProperties.load(FileInputStream(rootProject.file("local.properties")))
} catch (_: Exception) {
localProperties.setProperty("debug.gdrive.clientId", "")
localProperties.setProperty("debug.gdrive.secret", "")
}
android { android {
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
@ -107,6 +118,16 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
resValue(
"string",
"debug_gdrive_secret",
localProperties.getProperty("debug.gdrive.secret") ?: ""
)
resValue(
"string",
"debug_gdrive_clientId",
localProperties.getProperty("debug.gdrive.clientId") ?: ""
)
} }
} }
flavorDimensions.add("state") flavorDimensions.add("state")
@ -260,8 +281,27 @@ dependencies {
// color palette for images -> colors // color palette for images -> colors
implementation("androidx.palette:palette-ktx:1.0.0") 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) { tasks.register("androidSourcesJar", Jar::class) {
archiveClassifier.set("sources") archiveClassifier.set("sources")
from(android.sourceSets.getByName("main").java.srcDirs) //full sources from(android.sourceSets.getByName("main").java.srcDirs) //full sources

View 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>

View file

@ -159,6 +159,17 @@
android:pathPrefix="/" android:pathPrefix="/"
android:scheme="https" /> android:scheme="https" />
</intent-filter> </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>
<activity <activity

View file

@ -81,6 +81,7 @@ import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.plugins.PluginManager.loadAllOnlinePlugins import com.lagradost.cloudstream3.plugins.PluginManager.loadAllOnlinePlugins
import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin
import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.BackupApis
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString
@ -303,6 +304,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
val mainPluginsLoadedEvent = val mainPluginsLoadedEvent =
Event<Boolean>() // homepage api, used to speed up time to load for homepage Event<Boolean>() // homepage api, used to speed up time to load for homepage
val afterRepositoryLoadedEvent = Event<Boolean>() val afterRepositoryLoadedEvent = Event<Boolean>()
val afterBackupRestoreEvent = Event<Unit>()
// kinda shitty solution, but cant com main->home otherwise for popups // kinda shitty solution, but cant com main->home otherwise for popups
val bookmarksUpdatedEvent = Event<Boolean>() val bookmarksUpdatedEvent = Event<Boolean>()
@ -681,6 +683,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java) broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java)
this.sendBroadcast(broadcastIntent) this.sendBroadcast(broadcastIntent)
afterPluginsLoadedEvent -= ::onAllPluginsLoaded afterPluginsLoadedEvent -= ::onAllPluginsLoaded
// run sync before app quits
BackupApis.forEach { it.addToQueueNow() }
super.onDestroy() super.onDestroy()
} }

View file

@ -1,5 +1,6 @@
package com.lagradost.cloudstream3.syncproviders package com.lagradost.cloudstream3.syncproviders
import androidx.annotation.WorkerThread
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
@ -12,6 +13,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
val aniListApi = AniListApi(0) val aniListApi = AniListApi(0)
val openSubtitlesApi = OpenSubtitlesApi(0) val openSubtitlesApi = OpenSubtitlesApi(0)
val simklApi = SimklApi(0) val simklApi = SimklApi(0)
val googleDriveApi = GoogleDriveApi(0)
val indexSubtitlesApi = IndexSubtitleApi() val indexSubtitlesApi = IndexSubtitleApi()
val addic7ed = Addic7ed() val addic7ed = Addic7ed()
val localListApi = LocalList() val localListApi = LocalList()
@ -19,13 +21,13 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
// used to login via app intent // used to login via app intent
val OAuth2Apis val OAuth2Apis
get() = listOf<OAuth2API>( get() = listOf<OAuth2API>(
malApi, aniListApi, simklApi malApi, aniListApi, simklApi, googleDriveApi
) )
// this needs init with context and can be accessed in settings // this needs init with context and can be accessed in settings
val accountManagers val accountManagers
get() = listOf( get() = listOf(
malApi, aniListApi, openSubtitlesApi, simklApi //nginxApi malApi, aniListApi, openSubtitlesApi, simklApi, googleDriveApi //, nginxApi
) )
// used for active syncing // used for active syncing
@ -34,8 +36,16 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi) SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi)
) )
// used for active backup
val BackupApis
get() = listOf<BackupAPI<*>>(
googleDriveApi
)
val inAppAuths val inAppAuths
get() = listOf(openSubtitlesApi)//, nginxApi) get() = listOf(
openSubtitlesApi, googleDriveApi//, nginxApi
)
val subtitleProviders val subtitleProviders
get() = listOf( get() = listOf(
@ -90,6 +100,12 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
// int array of all accounts indexes // int array of all accounts indexes
private val accountsKey get() = "${idPrefix}_accounts" private val accountsKey get() = "${idPrefix}_accounts"
// runs on startup
@WorkerThread
open suspend fun initialize() {
}
protected fun removeAccountKeys() { protected fun removeAccountKeys() {
removeKeys(accountId) removeKeys(accountId)
val accounts = getAccounts()?.toMutableList() ?: mutableListOf() val accounts = getAccounts()?.toMutableList() ?: mutableListOf()

View file

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

View file

@ -35,11 +35,6 @@ abstract class InAppAuthAPIManager(defIndex: Int) : AccountManager(defIndex), In
override val storesPasswordInPlainText = true override val storesPasswordInPlainText = true
override val requiresLogin = true override val requiresLogin = true
// runs on startup
@WorkerThread
open suspend fun initialize() {
}
override fun logOut() { override fun logOut() {
throw NotImplementedError() throw NotImplementedError()
} }

View file

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

View file

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

View file

@ -32,6 +32,7 @@ import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CommonActivity.showToast 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.afterPluginsLoadedEvent
import com.lagradost.cloudstream3.MainActivity.Companion.bookmarksUpdatedEvent import com.lagradost.cloudstream3.MainActivity.Companion.bookmarksUpdatedEvent
import com.lagradost.cloudstream3.MainActivity.Companion.mainPluginsLoadedEvent import com.lagradost.cloudstream3.MainActivity.Companion.mainPluginsLoadedEvent
@ -500,6 +501,61 @@ class HomeFragment : Fragment() {
fixGrid() 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 currentApiName: String? = null
private var toggleRandomButton = false private var toggleRandomButton = false

View file

@ -6,6 +6,8 @@ import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup 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.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.syncproviders.BackupAPI
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment 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.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
import org.checkerframework.framework.qual.Unused
import kotlin.math.abs import kotlin.math.abs
const val LIBRARY_FOLDER = "library_folder" const val LIBRARY_FOLDER = "library_folder"
@ -78,6 +83,7 @@ class LibraryFragment : Fragment() {
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View { ): View {
MainActivity.afterBackupRestoreEvent += ::onNewSyncData
val localBinding = FragmentLibraryBinding.inflate(inflater, container, false) val localBinding = FragmentLibraryBinding.inflate(inflater, container, false)
binding = localBinding binding = localBinding
return localBinding.root return localBinding.root
@ -90,6 +96,11 @@ class LibraryFragment : Fragment() {
super.onDestroyView() super.onDestroyView()
} }
override fun onDestroyView() {
super.onDestroyView()
MainActivity.afterBackupRestoreEvent -= ::onNewSyncData
}
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
binding?.viewpager?.currentItem?.let { currentItem -> binding?.viewpager?.currentItem?.let { currentItem ->
outState.putInt(VIEWPAGER_ITEM_KEY, currentItem) outState.putInt(VIEWPAGER_ITEM_KEY, currentItem)
@ -420,6 +431,21 @@ class LibraryFragment : Fragment() {
(binding?.viewpager?.adapter as? ViewpagerAdapter)?.rebind() (binding?.viewpager?.adapter as? ViewpagerAdapter)?.rebind()
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
} }
override fun onResume() {
super.onResume()
MainActivity.afterBackupRestoreEvent += ::onNewSyncData
}
override fun onStop() {
super.onStop()
MainActivity.afterBackupRestoreEvent -= ::onNewSyncData
}
private fun onNewSyncData(unused: Unit) {
Log.d(BackupAPI.LOG_KEY, "will reload pages")
libraryViewModel.reloadPages(true)
}
} }
class MenuSearchView(context: Context) : SearchView(context) { class MenuSearchView(context: Context) : SearchView(context) {

View file

@ -31,6 +31,7 @@ import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding
import com.lagradost.cloudstream3.mvvm.* import com.lagradost.cloudstream3.mvvm.*
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage
import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding
import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType
@ -42,6 +43,7 @@ import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAu
import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.*
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage
import com.lagradost.cloudstream3.utils.SubtitleHelper.languages import com.lagradost.cloudstream3.utils.SubtitleHelper.languages
@ -713,6 +715,7 @@ class GeneratorPlayer : FullScreenPlayer() {
binding.subtitlesClickSettings.setOnClickListener { binding.subtitlesClickSettings.setOnClickListener {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
.attachBackupListener(ctx.getSyncPrefs()).self
val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list)
val prefValues = ctx.resources.getStringArray(R.array.subtitles_encoding_values) val prefValues = ctx.resources.getStringArray(R.array.subtitles_encoding_values)

View file

@ -2,25 +2,21 @@ package com.lagradost.cloudstream3.ui.settings
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.view.View.*
import android.view.inputmethod.EditorInfo
import android.widget.TextView import android.widget.TextView
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView 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.R
import com.lagradost.cloudstream3.databinding.AccountManagmentBinding import com.lagradost.cloudstream3.databinding.AccountManagmentBinding
import com.lagradost.cloudstream3.databinding.AccountSwitchBinding import com.lagradost.cloudstream3.databinding.AccountSwitchBinding
import com.lagradost.cloudstream3.databinding.AddAccountInputBinding import com.lagradost.cloudstream3.databinding.AddAccountInputBinding
import com.lagradost.cloudstream3.mvvm.logError 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.aniListApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.googleDriveApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi 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.isTvSettings
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar 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.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.setImage import com.lagradost.cloudstream3.utils.UIHelper.setImage
@ -260,6 +257,7 @@ class SettingsAccount : PreferenceFragmentCompat() {
R.string.anilist_key to aniListApi, R.string.anilist_key to aniListApi,
R.string.simkl_key to simklApi, R.string.simkl_key to simklApi,
R.string.opensubtitles_key to openSubtitlesApi, R.string.opensubtitles_key to openSubtitlesApi,
R.string.gdrive_key to googleDriveApi
) )
for ((key, api) in syncApis) { for ((key, api) in syncApis) {

View file

@ -25,10 +25,12 @@ import com.lagradost.cloudstream3.databinding.AddSiteInputBinding
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.network.initClient import com.lagradost.cloudstream3.network.initClient
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
import com.lagradost.cloudstream3.ui.EasterEggMonke import com.lagradost.cloudstream3.ui.EasterEggMonke
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
@ -146,13 +148,19 @@ class SettingsGeneral : PreferenceFragmentCompat() {
// Stores the real URI using download_path_key // Stores the real URI using download_path_key
// Important that the URI is stored instead of filepath due to permissions. // Important that the URI is stored instead of filepath due to permissions.
PreferenceManager.getDefaultSharedPreferences(context) PreferenceManager.getDefaultSharedPreferences(context)
.edit().putString(getString(R.string.download_path_key), uri.toString()).apply() .attachBackupListener(context.getSyncPrefs()).self
.edit()
.putString(getString(R.string.download_path_key), uri.toString())
.apply()
// From URI -> File path // From URI -> File path
// File path here is purely for cosmetic purposes in settings // File path here is purely for cosmetic purposes in settings
(filePath ?: uri.toString()).let { (filePath ?: uri.toString()).let {
PreferenceManager.getDefaultSharedPreferences(context) PreferenceManager.getDefaultSharedPreferences(context)
.edit().putString(getString(R.string.download_path_pref), it).apply() .attachBackupListener(context.getSyncPrefs()).self
.edit()
.putString(getString(R.string.download_path_pref), it)
.apply()
} }
} }
@ -160,6 +168,7 @@ class SettingsGeneral : PreferenceFragmentCompat() {
hideKeyboard() hideKeyboard()
setPreferencesFromResource(R.xml.settins_general, rootKey) setPreferencesFromResource(R.xml.settins_general, rootKey)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
.attachBackupListener(requireContext().getSyncPrefs()).self
fun getCurrent(): MutableList<CustomSite> { fun getCurrent(): MutableList<CustomSite> {
return getKey<Array<CustomSite>>(USER_PROVIDER_API)?.toMutableList() return getKey<Array<CustomSite>>(USER_PROVIDER_API)?.toMutableList()

View file

@ -7,12 +7,14 @@ import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getFolderSize 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.getPref
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
@ -28,6 +30,7 @@ class SettingsPlayer : PreferenceFragmentCompat() {
hideKeyboard() hideKeyboard()
setPreferencesFromResource(R.xml.settings_player, rootKey) setPreferencesFromResource(R.xml.settings_player, rootKey)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
.attachBackupListener(requireContext().getSyncPrefs()).self
getPref(R.string.video_buffer_length_key)?.setOnPreferenceClickListener { getPref(R.string.video_buffer_length_key)?.setOnPreferenceClickListener {
val prefNames = resources.getStringArray(R.array.video_buffer_length_names) val prefNames = resources.getStringArray(R.array.video_buffer_length_names)

View file

@ -6,19 +6,25 @@ import androidx.navigation.NavOptions
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AllLanguagesName
import com.lagradost.cloudstream3.DubStatus
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.APIRepository
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.SubtitleHelper
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
class SettingsProviders : PreferenceFragmentCompat() { class SettingsProviders : PreferenceFragmentCompat() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -31,6 +37,7 @@ class SettingsProviders : PreferenceFragmentCompat() {
hideKeyboard() hideKeyboard()
setPreferencesFromResource(R.xml.settings_providers, rootKey) setPreferencesFromResource(R.xml.settings_providers, rootKey)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
.attachBackupListener(requireContext().getSyncPrefs()).self
getPref(R.string.display_sub_key)?.setOnPreferenceClickListener { getPref(R.string.display_sub_key)?.setOnPreferenceClickListener {
activity?.getApiDubstatusSettings()?.let { current -> activity?.getApiDubstatusSettings()?.let { current ->

View file

@ -8,11 +8,13 @@ import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchQuality
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv 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.showBottomDialog
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
@ -29,6 +31,7 @@ class SettingsUI : PreferenceFragmentCompat() {
hideKeyboard() hideKeyboard()
setPreferencesFromResource(R.xml.settins_ui, rootKey) setPreferencesFromResource(R.xml.settins_ui, rootKey)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
.attachBackupListener(requireContext().getSyncPrefs()).self
getPref(R.string.poster_ui_key)?.setOnPreferenceClickListener { getPref(R.string.poster_ui_key)?.setOnPreferenceClickListener {
val prefNames = resources.getStringArray(R.array.poster_ui_options) val prefNames = resources.getStringArray(R.array.poster_ui_options)

View file

@ -19,12 +19,14 @@ import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.databinding.LogcatBinding import com.lagradost.cloudstream3.databinding.LogcatBinding
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.network.initClient import com.lagradost.cloudstream3.network.initClient
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
import com.lagradost.cloudstream3.utils.BackupUtils.backup import com.lagradost.cloudstream3.utils.BackupUtils.backup
import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
@ -134,6 +136,7 @@ class SettingsUpdates : PreferenceFragmentCompat() {
getPref(R.string.apk_installer_key)?.setOnPreferenceClickListener { getPref(R.string.apk_installer_key)?.setOnPreferenceClickListener {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context) val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context)
.attachBackupListener(it.context.getSyncPrefs()).self
val prefNames = resources.getStringArray(R.array.apk_installer_pref) val prefNames = resources.getStringArray(R.array.apk_installer_pref)
val prefValues = resources.getIntArray(R.array.apk_installer_values) val prefValues = resources.getIntArray(R.array.apk_installer_values)

View file

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

View file

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

View file

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

View file

@ -16,8 +16,10 @@ import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentSetupLanguageBinding import com.lagradost.cloudstream3.databinding.FragmentSetupLanguageBinding
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
import com.lagradost.cloudstream3.ui.settings.appLanguages import com.lagradost.cloudstream3.ui.settings.appLanguages
import com.lagradost.cloudstream3.ui.settings.getCurrentLocale import com.lagradost.cloudstream3.ui.settings.getCurrentLocale
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.SubtitleHelper
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
@ -51,6 +53,7 @@ class SetupFragmentLanguage : Fragment() {
val ctx = context ?: return@normalSafeApiCall val ctx = context ?: return@normalSafeApiCall
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
.attachBackupListener(getSyncPrefs()).self
val arrayAdapter = val arrayAdapter =
ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice) ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice)

View file

@ -13,6 +13,8 @@ import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentSetupLayoutBinding import com.lagradost.cloudstream3.databinding.FragmentSetupLayoutBinding
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
import org.acra.ACRA import org.acra.ACRA
@ -43,6 +45,7 @@ class SetupFragmentLayout : Fragment() {
val ctx = context ?: return@normalSafeApiCall val ctx = context ?: return@normalSafeApiCall
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
.attachBackupListener(getSyncPrefs()).self
val prefNames = resources.getStringArray(R.array.app_layout) val prefNames = resources.getStringArray(R.array.app_layout)
val prefValues = resources.getIntArray(R.array.app_layout_values) val prefValues = resources.getIntArray(R.array.app_layout_values)

View file

@ -17,6 +17,10 @@ import com.lagradost.cloudstream3.databinding.FragmentSetupMediaBinding
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
import com.lagradost.cloudstream3.utils.DataStore.removeKey
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
class SetupFragmentMedia : Fragment() { class SetupFragmentMedia : Fragment() {
@ -45,6 +49,7 @@ class SetupFragmentMedia : Fragment() {
val ctx = context ?: return@normalSafeApiCall val ctx = context ?: return@normalSafeApiCall
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
.attachBackupListener(getSyncPrefs()).self
val arrayAdapter = val arrayAdapter =
ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice) ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice)

View file

@ -18,6 +18,8 @@ import com.lagradost.cloudstream3.databinding.FragmentSetupProviderLanguagesBind
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.SubtitleHelper
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
class SetupFragmentProviderLanguage : Fragment() { class SetupFragmentProviderLanguage : Fragment() {
var binding: FragmentSetupProviderLanguagesBinding? = null var binding: FragmentSetupProviderLanguagesBinding? = null
@ -46,6 +48,7 @@ class SetupFragmentProviderLanguage : Fragment() {
val ctx = context ?: return@normalSafeApiCall val ctx = context ?: return@normalSafeApiCall
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
.attachBackupListener(getSyncPrefs()).self
val arrayAdapter = val arrayAdapter =
ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice) ArrayAdapter<String>(ctx, R.layout.sort_bottom_single_choice)

View file

@ -28,7 +28,9 @@ import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.SubtitleSettingsBinding 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.ui.settings.SettingsFragment.Companion.isTrueTvSettings
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.DataStore.setKey
import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
@ -455,6 +457,7 @@ class SubtitlesFragment : Fragment() {
subtitlesFilterSubLang.setOnCheckedChangeListener { _, b -> subtitlesFilterSubLang.setOnCheckedChangeListener { _, b ->
context?.let { ctx -> context?.let { ctx ->
PreferenceManager.getDefaultSharedPreferences(ctx) PreferenceManager.getDefaultSharedPreferences(ctx)
.attachBackupListener(ctx.getSyncPrefs()).self
.edit() .edit()
.putBoolean(getString(R.string.filter_sub_lang_key), b) .putBoolean(getString(R.string.filter_sub_lang_key), b)
.apply() .apply()

View file

@ -3,6 +3,9 @@ package com.lagradost.cloudstream3.utils
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -11,10 +14,13 @@ import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainActivity.Companion.afterBackupRestoreEvent
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.plugins.PLUGINS_KEY import com.lagradost.cloudstream3.plugins.PLUGINS_KEY
import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL
import com.lagradost.cloudstream3.syncproviders.BackupAPI
import com.lagradost.cloudstream3.syncproviders.InAppOAuth2APIManager
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_TOKEN_KEY import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_TOKEN_KEY
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_UNIXTIME_KEY import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_UNIXTIME_KEY
@ -30,7 +36,9 @@ import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs
import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
import com.lagradost.cloudstream3.utils.DataStore.mapper import com.lagradost.cloudstream3.utils.DataStore.mapper
import com.lagradost.cloudstream3.utils.DataStore.removeKeyRaw
import com.lagradost.cloudstream3.utils.DataStore.setKeyRaw import com.lagradost.cloudstream3.utils.DataStore.setKeyRaw
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
import com.lagradost.cloudstream3.utils.UIHelper.requestRW import com.lagradost.cloudstream3.utils.UIHelper.requestRW
@ -43,11 +51,17 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
object BackupUtils { object BackupUtils {
enum class RestoreSource {
DATA, SETTINGS, SYNC;
val prefix = "$name/"
val syncPrefix = "${BackupAPI.SYNC_HISTORY_PREFIX}$prefix"
}
/** /**
* No sensitive or breaking data in the backup * No sensitive or breaking data in the backup
* */ * */
private val nonTransferableKeys = listOf( val nonTransferableKeys = listOf(
// When sharing backup we do not want to transfer what is essentially the password // When sharing backup we do not want to transfer what is essentially the password
ANILIST_TOKEN_KEY, ANILIST_TOKEN_KEY,
ANILIST_CACHED_LIST, ANILIST_CACHED_LIST,
@ -58,6 +72,8 @@ object BackupUtils {
MAL_CACHED_LIST, MAL_CACHED_LIST,
MAL_UNIXTIME_KEY, MAL_UNIXTIME_KEY,
MAL_USER_KEY, MAL_USER_KEY,
InAppOAuth2APIManager.K.TOKEN.value,
InAppOAuth2APIManager.K.IS_READY.value,
// The plugins themselves are not backed up // The plugins themselves are not backed up
PLUGINS_KEY, PLUGINS_KEY,
@ -69,12 +85,22 @@ object BackupUtils {
/** false if blacklisted key */ /** false if blacklisted key */
private fun String.isTransferable(): Boolean { private fun String.isTransferable(): Boolean {
return !nonTransferableKeys.contains(this) return !nonTransferableKeys.any { this.contains(it) }
} }
private var restoreFileSelector: ActivityResultLauncher<Array<String>>? = null private var restoreFileSelector: ActivityResultLauncher<Array<String>>? = null
// Kinda hack, but I couldn't think of a better way // Kinda hack, but I couldn't think of a better way
data class RestoreMapData(
val wantToRestore: MutableSet<String> = mutableSetOf(),
val successfulRestore: MutableSet<String> = mutableSetOf()
) {
fun addAll(data: RestoreMapData) {
wantToRestore.addAll(data.wantToRestore)
successfulRestore.addAll(data.successfulRestore)
}
}
data class BackupVars( data class BackupVars(
@JsonProperty("_Bool") val _Bool: Map<String, Boolean>?, @JsonProperty("_Bool") val _Bool: Map<String, Boolean>?,
@JsonProperty("_Int") val _Int: Map<String, Int>?, @JsonProperty("_Int") val _Int: Map<String, Int>?,
@ -82,18 +108,62 @@ object BackupUtils {
@JsonProperty("_Float") val _Float: Map<String, Float>?, @JsonProperty("_Float") val _Float: Map<String, Float>?,
@JsonProperty("_Long") val _Long: Map<String, Long>?, @JsonProperty("_Long") val _Long: Map<String, Long>?,
@JsonProperty("_StringSet") val _StringSet: Map<String, Set<String>?>?, @JsonProperty("_StringSet") val _StringSet: Map<String, Set<String>?>?,
) ) {
constructor() : this(
mapOf(),
mapOf(),
mapOf(),
mapOf(),
mapOf(),
mapOf(),
)
}
data class BackupFile( data class BackupFile(
@JsonProperty("datastore") val datastore: BackupVars, @JsonProperty("datastore") val datastore: BackupVars,
@JsonProperty("settings") val settings: BackupVars @JsonProperty("settings") val settings: BackupVars,
) @JsonProperty("sync-meta") val syncMeta: BackupVars = BackupVars(),
) {
fun restore(
ctx: Context,
source: RestoreSource,
restoreKeys: Set<String>? = null
): RestoreMapData {
val data = getData(source)
val successfulRestore = RestoreMapData()
successfulRestore.addAll(ctx.restoreMap(data._Bool, source, restoreKeys))
successfulRestore.addAll(ctx.restoreMap(data._Int, source, restoreKeys))
successfulRestore.addAll(ctx.restoreMap(data._String, source, restoreKeys))
successfulRestore.addAll(ctx.restoreMap(data._Float, source, restoreKeys))
successfulRestore.addAll(ctx.restoreMap(data._Long, source, restoreKeys))
successfulRestore.addAll(ctx.restoreMap(data._StringSet, source, restoreKeys))
return successfulRestore
}
fun getData(source: RestoreSource) = when (source) {
RestoreSource.SYNC -> syncMeta
RestoreSource.DATA -> datastore
RestoreSource.SETTINGS -> settings
}
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun Context.getBackup(): BackupFile { fun Context.getBackup(): BackupFile {
val syncDataPrefs = getSyncPrefs().all.filter { it.key.isTransferable() }
val allData = getSharedPrefs().all.filter { it.key.isTransferable() } val allData = getSharedPrefs().all.filter { it.key.isTransferable() }
val allSettings = getDefaultSharedPrefs().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( val allDataSorted = BackupVars(
allData.filter { it.value is Boolean } as? Map<String, Boolean>, allData.filter { it.value is Boolean } as? Map<String, Boolean>,
allData.filter { it.value is Int } as? Map<String, Int>, allData.filter { it.value is Int } as? Map<String, Int>,
@ -114,33 +184,45 @@ object BackupUtils {
return BackupFile( return BackupFile(
allDataSorted, allDataSorted,
allSettingsSorted allSettingsSorted,
syncData
) )
} }
@WorkerThread
fun Context.restore(backupFile: BackupFile, restoreKeys: Set<String>? = null) = restore(
backupFile,
restoreKeys,
RestoreSource.SYNC,
RestoreSource.DATA,
RestoreSource.SETTINGS
)
@WorkerThread @WorkerThread
fun Context.restore( fun Context.restore(
backupFile: BackupFile, backupFile: BackupFile,
restoreSettings: Boolean, restoreKeys: Set<String>? = null,
restoreDataStore: Boolean vararg restoreSources: RestoreSource
) { ) {
if (restoreSettings) { Log.d(BackupAPI.LOG_KEY, "will restore keys = $restoreKeys")
restoreMap(backupFile.settings._Bool, true)
restoreMap(backupFile.settings._Int, true) for (restoreSource in restoreSources) {
restoreMap(backupFile.settings._String, true) val restoreData = RestoreMapData()
restoreMap(backupFile.settings._Float, true)
restoreMap(backupFile.settings._Long, true) restoreData.addAll(backupFile.restore(this, restoreSource, restoreKeys))
restoreMap(backupFile.settings._StringSet, true)
// we must remove keys that are not present
if (!restoreKeys.isNullOrEmpty()) {
Log.d(BackupAPI.LOG_KEY, "successfulRestore for src=[${restoreSource.name}]: ${restoreData.successfulRestore}")
val removedKeys = restoreData.wantToRestore - restoreData.successfulRestore
Log.d(BackupAPI.LOG_KEY, "removed keys for src=[${restoreSource.name}]: $removedKeys")
removedKeys.forEach { removeKeyRaw(it, restoreSource) }
}
} }
if (restoreDataStore) { Log.d(BackupAPI.LOG_KEY, "restore on ui event fired")
restoreMap(backupFile.datastore._Bool) afterBackupRestoreEvent.invoke(Unit)
restoreMap(backupFile.datastore._Int)
restoreMap(backupFile.datastore._String)
restoreMap(backupFile.datastore._Float)
restoreMap(backupFile.datastore._Long)
restoreMap(backupFile.datastore._StringSet)
}
} }
@SuppressLint("SimpleDateFormat") @SuppressLint("SimpleDateFormat")
@ -195,14 +277,7 @@ object BackupUtils {
val input = activity.contentResolver.openInputStream(uri) val input = activity.contentResolver.openInputStream(uri)
?: return@ioSafe ?: return@ioSafe
val restoredValue = activity.restore(mapper.readValue(input))
mapper.readValue<BackupFile>(input)
activity.restore(
restoredValue,
restoreSettings = true,
restoreDataStore = true
)
activity.runOnUiThread { activity.recreate() } activity.runOnUiThread { activity.recreate() }
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
@ -242,10 +317,54 @@ object BackupUtils {
private fun <T> Context.restoreMap( private fun <T> Context.restoreMap(
map: Map<String, T>?, map: Map<String, T>?,
isEditingAppSettings: Boolean = false restoreSource: RestoreSource,
) { restoreKeys: Set<String>? = null
map?.filter { it.key.isTransferable() }?.forEach { ): RestoreMapData {
setKeyRaw(it.key, it.value, isEditingAppSettings) 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)

View file

@ -12,6 +12,7 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.setKeyClass
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
import com.lagradost.cloudstream3.syncproviders.BackupAPI
const val DOWNLOAD_HEADER_CACHE = "download_header_cache" 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 USER_PROVIDER_API = "user_custom_sites"
const val PREFERENCES_NAME = "rebuild_preference" const val PREFERENCES_NAME = "rebuild_preference"
const val SYNC_PREFERENCES_NAME = "rebuild_sync_preference"
// TODO degelgate by value for get & set // TODO degelgate by value for get & set
@ -52,12 +54,24 @@ class PreferenceDelegate<T : Any>(
object DataStore { object DataStore {
val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule()) val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(DeserializationFeature.USE_LONG_FOR_INTS, true)
.build()
private val backupScheduler = Scheduler.createBackupScheduler()
private fun getPreferences(context: Context): SharedPreferences { private fun getPreferences(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
} }
private fun getSyncPreferences(context: Context): SharedPreferences {
return context.getSharedPreferences(SYNC_PREFERENCES_NAME, Context.MODE_PRIVATE)
}
fun Context.getSyncPrefs(): SharedPreferences {
return getSyncPreferences(this)
}
fun Context.getSharedPrefs(): SharedPreferences { fun Context.getSharedPrefs(): SharedPreferences {
return getPreferences(this) return getPreferences(this)
} }
@ -65,11 +79,14 @@ object DataStore {
fun getFolderName(folder: String, path: String): String { fun getFolderName(folder: String, path: String): String {
return "${folder}/${path}" return "${folder}/${path}"
} }
fun <T> Context.setKeyRaw(path: String, value: T, restoreSource: BackupUtils.RestoreSource) {
fun <T> Context.setKeyRaw(path: String, value: T, isEditingAppSettings: Boolean = false) {
try { try {
val editor: SharedPreferences.Editor = val editor = when (restoreSource) {
if (isEditingAppSettings) getDefaultSharedPrefs().edit() else getSharedPrefs().edit() BackupUtils.RestoreSource.DATA -> getSharedPrefs().edit()
BackupUtils.RestoreSource.SETTINGS -> getDefaultSharedPrefs().edit()
BackupUtils.RestoreSource.SYNC -> getSyncPrefs().edit()
}
when (value) { when (value) {
is Boolean -> editor.putBoolean(path, value) is Boolean -> editor.putBoolean(path, value)
is Int -> editor.putInt(path, value) is Int -> editor.putInt(path, value)
@ -83,6 +100,17 @@ object DataStore {
logError(e) logError(e)
} }
} }
fun Context.removeKeyRaw(path: String, restoreSource: BackupUtils.RestoreSource) {
try {
when (restoreSource) {
BackupUtils.RestoreSource.DATA -> getSharedPrefs().edit()
BackupUtils.RestoreSource.SETTINGS -> getDefaultSharedPrefs().edit()
BackupUtils.RestoreSource.SYNC -> getSyncPrefs().edit()
}.remove(path).apply()
} catch (e: Exception) {
logError(e)
}
}
fun Context.getDefaultSharedPrefs(): SharedPreferences { fun Context.getDefaultSharedPrefs(): SharedPreferences {
return PreferenceManager.getDefaultSharedPreferences(this) return PreferenceManager.getDefaultSharedPreferences(this)
@ -109,9 +137,21 @@ object DataStore {
try { try {
val prefs = getSharedPrefs() val prefs = getSharedPrefs()
if (prefs.contains(path)) { if (prefs.contains(path)) {
val oldValueExists = prefs.getString(path, null) != null
val editor: SharedPreferences.Editor = prefs.edit() val editor: SharedPreferences.Editor = prefs.edit()
editor.remove(path) editor.remove(path)
editor.apply() editor.apply()
backupScheduler.work(
BackupAPI.PreferencesSchedulerData(
getSyncPrefs(),
path,
oldValueExists,
false,
BackupUtils.RestoreSource.DATA
)
)
} }
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
@ -128,9 +168,23 @@ object DataStore {
fun <T> Context.setKey(path: String, value: T) { fun <T> Context.setKey(path: String, value: T) {
try { try {
val editor: SharedPreferences.Editor = getSharedPrefs().edit() val prefs = getSharedPrefs()
editor.putString(path, mapper.writeValueAsString(value)) val oldValue = prefs.getString(path, null)
val newValue = mapper.writeValueAsString(value)
val editor: SharedPreferences.Editor = prefs.edit()
editor.putString(path, newValue)
editor.apply() editor.apply()
backupScheduler.work(
BackupAPI.PreferencesSchedulerData(
getSyncPrefs(),
path,
oldValue,
newValue,
BackupUtils.RestoreSource.DATA
)
)
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
} }
@ -149,6 +203,7 @@ object DataStore {
setKey(getFolderName(folder, path), value) setKey(getFolderName(folder, path), value)
} }
inline fun <reified T : Any> String.toKotlinObject(): T { inline fun <reified T : Any> String.toKotlinObject(): T {
return mapper.readValue(this, T::class.java) return mapper.readValue(this, T::class.java)
} }

View file

@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.text.TextUtils
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
@ -14,17 +15,18 @@ import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.utils.Scheduler.Companion.attachBackupListener
import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStore.getSyncPrefs
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import okio.BufferedSink import okio.BufferedSink
import okio.buffer import okio.buffer
import okio.sink 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.BufferedReader
import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStreamReader import java.io.InputStreamReader
@ -74,6 +76,7 @@ class InAppUpdater {
private suspend fun Activity.getAppUpdate(): Update { private suspend fun Activity.getAppUpdate(): Update {
return try { return try {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
.attachBackupListener(getSyncPrefs()).self
if (settingsManager.getBoolean( if (settingsManager.getBoolean(
getString(R.string.prerelease_update_key), getString(R.string.prerelease_update_key),
resources.getBoolean(R.bool.is_prerelease) resources.getBoolean(R.bool.is_prerelease)
@ -255,7 +258,9 @@ class InAppUpdater {
* @param checkAutoUpdate if the update check was launched automatically * @param checkAutoUpdate if the update check was launched automatically
**/ **/
suspend fun Activity.runAutoUpdate(checkAutoUpdate: Boolean = true): Boolean { suspend fun Activity.runAutoUpdate(checkAutoUpdate: Boolean = true): Boolean {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val settingsManager =
PreferenceManager.getDefaultSharedPreferences(this)
.attachBackupListener(getSyncPrefs()).self
if (!checkAutoUpdate || settingsManager.getBoolean( if (!checkAutoUpdate || settingsManager.getBoolean(
getString(R.string.auto_update_key), getString(R.string.auto_update_key),
@ -265,7 +270,8 @@ class InAppUpdater {
val update = getAppUpdate() val update = getAppUpdate()
if ( if (
update.shouldUpdate && update.shouldUpdate &&
update.updateURL != null) { update.updateURL != null
) {
// Check if update should be skipped // Check if update should be skipped
val updateNodeId = val updateNodeId =

View 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)
}
}

View 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>

View file

@ -448,10 +448,13 @@
<string name="bottom_title_settings">Poster title location</string> <string name="bottom_title_settings">Poster title location</string>
<string name="bottom_title_settings_des">Put the title under the poster</string> <string name="bottom_title_settings_des">Put the title under the poster</string>
<!-- account stuff --> <!-- account stuff -->
<string name="settings_category_plugins">Plugins</string>
<string name="settings_category_remote_sync">Remote Sync</string>
<string name="anilist_key" translatable="false">anilist_key</string> <string name="anilist_key" translatable="false">anilist_key</string>
<string name="simkl_key" translatable="false">simkl_key</string> <string name="simkl_key" translatable="false">simkl_key</string>
<string name="mal_key" translatable="false">mal_key</string> <string name="mal_key" translatable="false">mal_key</string>
<string name="opensubtitles_key" translatable="false">opensubtitles_key</string> <string name="opensubtitles_key" translatable="false">opensubtitles_key</string>
<string name="gdrive_key" translatable="false">gdrive_key</string>
<string name="nginx_key" translatable="false">nginx_key</string> <string name="nginx_key" translatable="false">nginx_key</string>
<string name="example_password">password123</string> <string name="example_password">password123</string>
<string name="example_username">MyCoolUsername</string> <string name="example_username">MyCoolUsername</string>
@ -460,6 +463,9 @@
<string name="example_site_name">MyCoolSite</string> <string name="example_site_name">MyCoolSite</string>
<string name="example_site_url">example.com</string> <string name="example_site_url">example.com</string>
<string name="example_lang_name">Language code (en)</string> <string name="example_lang_name">Language code (en)</string>
<string name="example_login_file_name" translatable="false">cloudstreamapp-sync-file</string>
<string name="example_login_client_id">OAuth Client ID</string>
<string name="example_login_client_secret">OAuth Client Secret</string>
<!-- <!--
<string name="mal_account_settings" translatable="false">MAL</string> <string name="mal_account_settings" translatable="false">MAL</string>
<string name="anilist_account_settings" translatable="false">AniList</string> <string name="anilist_account_settings" translatable="false">AniList</string>
@ -684,8 +690,11 @@
<string name="qualities">Qualities</string> <string name="qualities">Qualities</string>
<string name="profile_background_des">Profile background</string> <string name="profile_background_des">Profile background</string>
<string name="unable_to_inflate">UI was unable to be created correctly, this is a MAJOR BUG and should be reported immediately %s</string> <string name="unable_to_inflate">UI was unable to be created correctly, this is a MAJOR BUG and should be reported immediately %s</string>
<string name="tv_no_focus_tag" translatable="false">tv_no_focus_tag</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="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> </resources>

View file

@ -1,24 +1,26 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" <PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<Preference <PreferenceCategory android:title="@string/settings_category_plugins">
android:icon="@drawable/mal_logo" <Preference
android:key="@string/mal_key" /> android:key="@string/mal_key"
android:icon="@drawable/mal_logo" />
<Preference <Preference
android:icon="@drawable/ic_anilist_icon" android:key="@string/anilist_key"
android:key="@string/anilist_key" /> android:icon="@drawable/ic_anilist_icon" />
<Preference
<Preference android:key="@string/opensubtitles_key"
android:icon="@drawable/simkl_logo" android:icon="@drawable/open_subtitles_icon" />
android:key="@string/simkl_key" /> </PreferenceCategory>
<PreferenceCategory android:title="@string/settings_category_remote_sync">
<Preference <Preference
android:icon="@drawable/open_subtitles_icon" android:key="@string/gdrive_key"
android:key="@string/opensubtitles_key" /> android:icon="@drawable/ic_baseline_add_to_drive_24" />
<!-- <Preference--> </PreferenceCategory>
<!-- android:key="@string/nginx_key"--> <!-- <Preference-->
<!-- android:icon="@drawable/nginx" />--> <!-- android:key="@string/nginx_key"-->
<!-- android:icon="@drawable/nginx" />-->
<!-- <Preference--> <!-- <Preference-->
<!-- android:title="@string/nginx_info_title"--> <!-- android:title="@string/nginx_info_title"-->