feat: add remote sync capability - POC

This commit is contained in:
Martin Filo 2023-04-03 21:43:51 +02:00
parent 4ed65f8e07
commit 1122137d18
17 changed files with 1051 additions and 142 deletions

View file

@ -2,6 +2,8 @@ import com.android.build.gradle.api.BaseVariantOutput
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")
@ -19,11 +21,14 @@ fun String.execute() = ByteArrayOutputStream().use { baot ->
workingDir = projectDir workingDir = projectDir
commandLine = this@execute.split(Regex("\\s")) commandLine = this@execute.split(Regex("\\s"))
standardOutput = baot standardOutput = baot
}.exitValue == 0) }.exitValue == 0)
String(baot.toByteArray()).trim() String(baot.toByteArray()).trim()
else null else null
} }
val localProperties = Properties()
localProperties.load(FileInputStream(rootProject.file("local.properties")))
android { android {
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
@ -79,7 +84,20 @@ android {
debug { debug {
isDebuggable = true isDebuggable = true
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
resValue(
"string",
"debug_gdrive_secret",
localProperties.getProperty("debug.gdrive.secret") ?: ""
)
resValue(
"string",
"debug_gdrive_clientId",
localProperties.getProperty("debug.gdrive.clientId") ?: ""
)
} }
} }
flavorDimensions.add("state") flavorDimensions.add("state")
@ -225,8 +243,26 @@ dependencies {
// color pallette for images -> colors // color pallette for images -> colors
implementation("androidx.palette:palette-ktx:1.0.0") implementation("androidx.palette:palette-ktx:1.0.0")
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,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="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"/>
</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

@ -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
@ -11,6 +12,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
val malApi = MALApi(0) val malApi = MALApi(0)
val aniListApi = AniListApi(0) val aniListApi = AniListApi(0)
val openSubtitlesApi = OpenSubtitlesApi(0) val openSubtitlesApi = OpenSubtitlesApi(0)
val googleDriveApi = GoogleDriveApi(0)
val indexSubtitlesApi = IndexSubtitleApi() val indexSubtitlesApi = IndexSubtitleApi()
val addic7ed = Addic7ed() val addic7ed = Addic7ed()
val localListApi = LocalList() val localListApi = LocalList()
@ -18,13 +20,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 malApi, aniListApi, 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, //nginxApi malApi, aniListApi, openSubtitlesApi, googleDriveApi //, nginxApi
) )
// used for active syncing // used for active syncing
@ -33,8 +35,16 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi) SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi)
) )
// 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(
@ -89,6 +99,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,36 @@
package com.lagradost.cloudstream3.syncproviders
import android.content.Context
import com.lagradost.cloudstream3.mvvm.launchSafe
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
interface BackupAPI<LOGIN_DATA> {
private companion object {
val DEBOUNCE_TIME_MS = 15.seconds
}
fun downloadSyncData()
fun uploadSyncData()
fun shouldUpdate(): Boolean
fun Context.mergeBackup(incomingData: String)
fun Context.createBackup(loginData: LOGIN_DATA)
var uploadJob: Job?
fun addToQueue() {
if (!shouldUpdate()) {
return
}
uploadJob?.cancel()
uploadJob = CoroutineScope(Dispatchers.IO).launchSafe {
delay(DEBOUNCE_TIME_MS)
uploadSyncData()
}
}
}

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,63 @@
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
// 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,
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,396 @@
package com.lagradost.cloudstream3.syncproviders.providers
import android.content.Context
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.module.kotlin.readValue
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.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.mvvm.launchSafe
import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.BackupAPI
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
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.DataStore.removeKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import java.io.InputStream
import java.util.*
// TODO: improvements and ideas
// - add option to use proper oauth through google services one tap - would need google console project on behalf of cloudstream
// - encrypt data on drive
// - choose what should be synced
// - having a button to run sync would be nice
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://chiff.github.io/cloudstream-sync/google-drive" // TODO: we should move this one to cs3 repo
override var uploadJob: Job? = null
var tempAuthFlow: AuthorizationCodeFlow? = null
var lastBackupJson: String? = null
var continuousDownloadJob: Job? = 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)
startContinuousDownload()
tempAuthFlow = null
return true
}
/////////////////////////////////////////
/////////////////////////////////////////
// InAppOAuth2APIManager implementation
override suspend fun initialize() {
if (loginInfo() == null) {
return
}
startContinuousDownload()
}
private fun startContinuousDownload() {
continuousDownloadJob?.cancel()
continuousDownloadJob = CoroutineScope(Dispatchers.IO).launchSafe {
if (uploadJob?.isActive == true) {
uploadJob!!.invokeOnCompletion {
startContinuousDownload()
}
} else {
downloadSyncData()
delay(1000 * 60)
startContinuousDownload()
}
}
}
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.LOGIN_DATA, data)
val authFlow = GAPI.createAuthFlow(data.clientId, data.secret)
this.tempAuthFlow = authFlow
try {
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<InAppOAuth2API.LoginData>(K.LOGIN_DATA)
}
/////////////////////////////////////////
/////////////////////////////////////////
// BackupAPI implementation
override fun Context.mergeBackup(incomingData: String) {
val currentData = getBackup()
val newData = DataStore.mapper.readValue<BackupUtils.BackupFile>(incomingData)
getRedundantKeys(currentData, newData).forEach {
removeKey(it)
}
restore(
newData,
restoreSettings = true,
restoreDataStore = true
)
}
// 🤮
private fun getRedundantKeys(
old: BackupUtils.BackupFile,
new: BackupUtils.BackupFile
): List<String> = mutableListOf(
*getRedundant(old.settings._Bool, new.settings._Bool),
*getRedundant(old.settings._Long, new.settings._Long),
*getRedundant(old.settings._Float, new.settings._Float),
*getRedundant(old.settings._Int, new.settings._Int),
*getRedundant(old.settings._String, new.settings._String),
*getRedundant(old.settings._StringSet, new.settings._StringSet),
*getRedundant(old.datastore._Bool, new.datastore._Bool),
*getRedundant(old.datastore._Long, new.datastore._Long),
*getRedundant(old.datastore._Float, new.datastore._Float),
*getRedundant(old.datastore._Int, new.datastore._Int),
*getRedundant(old.datastore._String, new.datastore._String),
*getRedundant(old.datastore._StringSet, new.datastore._StringSet),
)
private fun getRedundant(old: Map<String, *>?, new: Map<String, *>?): Array<String> =
old.orEmpty().keys.subtract(new.orEmpty().keys).toTypedArray()
override fun Context.createBackup(loginData: InAppOAuth2API.LoginData) {
val drive = getDriveService()!!
val fileName = loginData.fileName
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 = getOrCreateSyncFileId(drive, loginData)
if (fileId != null) {
try {
val file = drive.files().update(fileId, fileMetadata, fileContent).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
}
storeValue(K.LOGIN_DATA, loginData)
}
override fun downloadSyncData() {
val ctx = AcraApplication.context ?: return
val drive = getDriveService() ?: return
val loginData = getLatestLoginData() ?: return
val existingFileId = getOrCreateSyncFileId(drive, loginData)
val existingFile = if (existingFileId != null) {
try {
drive.files().get(existingFileId)
} catch (_: Exception) {
null
}
} else {
null
}
if (existingFile != null) {
try {
val inputStream: InputStream = existingFile.executeMediaAsInputStream()
val content: String = inputStream.bufferedReader().use { it.readText() }
ctx.mergeBackup(content)
return
} catch (_: Exception) {
}
} else {
uploadSyncData()
}
}
private fun getOrCreateSyncFileId(drive: Drive, loginData: InAppOAuth2API.LoginData): String? {
val existingFileId: String? = loginData.syncFileId ?: drive
.files()
.list()
.setQ("name='${loginData.fileName}' and trashed=false")
.execute()
.files
?.getOrNull(0)
?.id
if (existingFileId != null && loginData.syncFileId == null) {
loginData.syncFileId = existingFileId
storeValue(K.LOGIN_DATA, loginData)
return existingFileId
}
val verifyId = drive.files().get(existingFileId)
return if (verifyId == null) {
return null
} else {
existingFileId
}
}
override fun uploadSyncData() {
val ctx = AcraApplication.context ?: return
val loginData = getLatestLoginData() ?: return
ctx.createBackup(loginData)
}
override fun shouldUpdate(): Boolean {
val ctx = AcraApplication.context ?: return false
val newBackup = ctx.getBackup().toJson()
return lastBackupJson != newBackup
}
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 fun getCredentialsFromStore(): Credential? {
val LOGIN_DATA = getLatestLoginData()
val TOKEN = getValue<TokenResponse>(K.TOKEN)
val credential = if (LOGIN_DATA != null && TOKEN != null) {
GAPI.getCredentials(TOKEN, LOGIN_DATA)
} 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 = GoogleNetHttpTransport.newTrustedTransport()
val JSON_FACTORY = 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

@ -2,38 +2,31 @@ 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.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.AuthAPI
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
import com.lagradost.cloudstream3.syncproviders.OAuth2API
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.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
import kotlinx.android.synthetic.main.account_managment.* import kotlinx.android.synthetic.main.account_managment.*
import kotlinx.android.synthetic.main.account_switch.* import kotlinx.android.synthetic.main.account_switch.*
import kotlinx.android.synthetic.main.add_account_input.*
class SettingsAccount : PreferenceFragmentCompat() { class SettingsAccount : PreferenceFragmentCompat() {
companion object { companion object {
@ -108,122 +101,9 @@ class SettingsAccount : PreferenceFragmentCompat() {
fun addAccount(activity: FragmentActivity?, api: AccountManager) { fun addAccount(activity: FragmentActivity?, api: AccountManager) {
try { try {
when (api) { when (api) {
is OAuth2API -> { is InAppOAuth2API -> InAppOAuth2DialogBuilder(api, activity).open()
api.authenticate(activity) is OAuth2API -> api.authenticate(activity)
} is InAppAuthAPI -> InAppAuthDialogBuilder(api, activity).open()
is InAppAuthAPI -> {
val builder =
AlertDialog.Builder(activity ?: return, R.style.AlertDialogCustom)
.setView(R.layout.add_account_input)
val dialog = builder.show()
val visibilityMap = mapOf(
dialog.login_email_input to api.requiresEmail,
dialog.login_password_input to api.requiresPassword,
dialog.login_server_input to api.requiresServer,
dialog.login_username_input to api.requiresUsername
)
if (isTvSettings()) {
visibilityMap.forEach { (input, isVisible) ->
input.isVisible = isVisible
// Band-aid for weird FireTV behavior causing crashes because keyboard covers the screen
input.setOnEditorActionListener { textView, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_NEXT) {
val view = textView.focusSearch(FOCUS_DOWN)
return@setOnEditorActionListener view?.requestFocus(
FOCUS_DOWN
) == true
}
return@setOnEditorActionListener true
}
}
} else {
visibilityMap.forEach { (input, isVisible) ->
input.isVisible = isVisible
}
}
dialog.login_email_input?.isVisible = api.requiresEmail
dialog.login_password_input?.isVisible = api.requiresPassword
dialog.login_server_input?.isVisible = api.requiresServer
dialog.login_username_input?.isVisible = api.requiresUsername
dialog.create_account?.isGone = api.createAccountUrl.isNullOrBlank()
dialog.create_account?.setOnClickListener {
openBrowser(
api.createAccountUrl ?: return@setOnClickListener,
activity
)
dialog.dismissSafe()
}
val displayedItems = listOf(
dialog.login_username_input,
dialog.login_email_input,
dialog.login_server_input,
dialog.login_password_input
).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 {
dialog.create_account?.nextFocusDownId = it.id
it.nextFocusUpId = dialog.create_account.id
}
dialog.apply_btt?.id?.let {
displayedItems.lastOrNull()?.nextFocusDownId = it
}
dialog.text1?.text = api.name
if (api.storesPasswordInPlainText) {
api.getLatestLoginData()?.let { data ->
dialog.login_email_input?.setText(data.email ?: "")
dialog.login_server_input?.setText(data.server ?: "")
dialog.login_username_input?.setText(data.username ?: "")
dialog.login_password_input?.setText(data.password ?: "")
}
}
dialog.apply_btt?.setOnClickListener {
val loginData = InAppAuthAPI.LoginData(
username = if (api.requiresUsername) dialog.login_username_input?.text?.toString() else null,
password = if (api.requiresPassword) dialog.login_password_input?.text?.toString() else null,
email = if (api.requiresEmail) dialog.login_email_input?.text?.toString() else null,
server = if (api.requiresServer) dialog.login_server_input?.text?.toString() else null,
)
ioSafe {
val isSuccessful = try {
api.login(loginData)
} catch (e: Exception) {
logError(e)
false
}
activity.runOnUiThread {
try {
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
}
}
}
dialog.dismissSafe(activity)
}
dialog.cancel_btt?.setOnClickListener {
dialog.dismissSafe(activity)
}
}
else -> { else -> {
throw NotImplementedError("You are trying to add an account that has an unknown login method") throw NotImplementedError("You are trying to add an account that has an unknown login method")
} }
@ -249,6 +129,7 @@ class SettingsAccount : PreferenceFragmentCompat() {
R.string.mal_key to malApi, R.string.mal_key to malApi,
R.string.anilist_key to aniListApi, R.string.anilist_key to aniListApi,
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

@ -0,0 +1,130 @@
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 btnConfirmOauth: MaterialButton?
) {
fun getTitle() = dialog.getCommonItem(title)!!
fun getBtnApply() = dialog.getCommonItem(btnApply)!!
fun getBtnCancel() = dialog.getCommonItem(btnCancel)!!
fun getBtnAccCreate() = dialog.getCommonItem(btnAccCreate)
fun getBtnConfirm() = dialog.getCommonItem(btnConfirmOauth)
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
}
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,68 @@
package com.lagradost.cloudstream3.ui.settings.helpers.settings.account
import android.view.View
import androidx.appcompat.app.AlertDialog
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.*
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, null)
}
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
}
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

@ -18,6 +18,7 @@ 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.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
@ -60,6 +61,7 @@ object BackupUtils {
MAL_CACHED_LIST, MAL_CACHED_LIST,
MAL_UNIXTIME_KEY, MAL_UNIXTIME_KEY,
MAL_USER_KEY, MAL_USER_KEY,
InAppOAuth2APIManager.K.TOKEN.value,
// The plugins themselves are not backed up // The plugins themselves are not backed up
PLUGINS_KEY, PLUGINS_KEY,
@ -71,7 +73,7 @@ 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

View file

@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule import com.fasterxml.jackson.module.kotlin.KotlinModule
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AccountManager
const val DOWNLOAD_HEADER_CACHE = "download_header_cache" const val DOWNLOAD_HEADER_CACHE = "download_header_cache"
@ -20,7 +21,9 @@ const val PREFERENCES_NAME = "rebuild_preference"
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 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)
@ -80,6 +83,8 @@ object DataStore {
val editor: SharedPreferences.Editor = prefs.edit() val editor: SharedPreferences.Editor = prefs.edit()
editor.remove(path) editor.remove(path)
editor.apply() editor.apply()
AccountManager.BackupApis.forEach { it.addToQueue() }
} }
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
@ -99,6 +104,8 @@ object DataStore {
val editor: SharedPreferences.Editor = getSharedPrefs().edit() val editor: SharedPreferences.Editor = getSharedPrefs().edit()
editor.putString(path, mapper.writeValueAsString(value)) editor.putString(path, mapper.writeValueAsString(value))
editor.apply() editor.apply()
AccountManager.BackupApis.forEach { it.addToQueue() }
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
} }

View file

@ -0,0 +1,145 @@
<?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" />
</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

@ -446,9 +446,12 @@
<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="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>
@ -457,6 +460,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>
@ -657,4 +663,9 @@
<string name="subscription_new">Subscribed to %s</string> <string name="subscription_new">Subscribed to %s</string>
<string name="subscription_deleted">Unsubscribed from %s</string> <string name="subscription_deleted">Unsubscribed from %s</string>
<string name="subscription_episode_released">Episode %d released!</string> <string name="subscription_episode_released">Episode %d released!</string>
<string name="example_login_client_confirmation_code">OAuth Confirmation Code</string>
<string name="confirm_next">Confirm</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">https://chiff.github.io/cloudstream-sync/google-drive</string>
</resources> </resources>

View file

@ -1,16 +1,23 @@
<?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">
<Preference
android:key="@string/mal_key" android:key="@string/mal_key"
android:icon="@drawable/mal_logo" /> android:icon="@drawable/mal_logo" />
<Preference <Preference
android:key="@string/anilist_key" android:key="@string/anilist_key"
android:icon="@drawable/ic_anilist_icon" /> android:icon="@drawable/ic_anilist_icon" />
<Preference <Preference
android:key="@string/opensubtitles_key" android:key="@string/opensubtitles_key"
android:icon="@drawable/open_subtitles_icon" /> android:icon="@drawable/open_subtitles_icon" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/settings_category_remote_sync">
<Preference
android:key="@string/gdrive_key"
android:icon="@drawable/ic_baseline_add_to_drive_24" />
</PreferenceCategory>
<!-- <Preference--> <!-- <Preference-->
<!-- android:key="@string/nginx_key"--> <!-- android:key="@string/nginx_key"-->
<!-- android:icon="@drawable/nginx" />--> <!-- android:icon="@drawable/nginx" />-->