mirror of
https://github.com/recloudstream/cloudstream.git
synced 2024-08-15 01:53:11 +00:00
feat: add remote sync capability - POC
This commit is contained in:
parent
4ed65f8e07
commit
1122137d18
17 changed files with 1051 additions and 142 deletions
|
@ -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
|
||||||
|
|
|
@ -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>
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,100 @@
|
||||||
|
package com.lagradost.cloudstream3.ui.settings.helpers.settings.account
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication
|
||||||
|
import com.lagradost.cloudstream3.CommonActivity
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
||||||
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||||
|
import kotlinx.android.synthetic.main.add_account_input.*
|
||||||
|
|
||||||
|
class InAppAuthDialogBuilder(
|
||||||
|
private val api: InAppAuthAPI,
|
||||||
|
private val activity: FragmentActivity?,
|
||||||
|
) : DialogBuilder(
|
||||||
|
api,
|
||||||
|
activity,
|
||||||
|
R.style.AlertDialogCustom,
|
||||||
|
R.layout.add_account_input,
|
||||||
|
) {
|
||||||
|
|
||||||
|
override fun onLogin(dialog: AlertDialog): Unit = with(dialog) {
|
||||||
|
if (activity == null) throw IllegalStateException("Login should be called after validation")
|
||||||
|
|
||||||
|
val loginData = InAppAuthAPI.LoginData(
|
||||||
|
username = if (api.requiresUsername) login_username_input?.text?.toString() else null,
|
||||||
|
password = if (api.requiresPassword) login_password_input?.text?.toString() else null,
|
||||||
|
email = if (api.requiresEmail) login_email_input?.text?.toString() else null,
|
||||||
|
server = if (api.requiresServer) login_server_input?.text?.toString() else null,
|
||||||
|
)
|
||||||
|
|
||||||
|
ioSafe {
|
||||||
|
val isSuccessful = try {
|
||||||
|
api.login(loginData)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
activity.runOnUiThread {
|
||||||
|
try {
|
||||||
|
CommonActivity.showToast(
|
||||||
|
activity,
|
||||||
|
activity.getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail)
|
||||||
|
.format(
|
||||||
|
api.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logError(e) // format might fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCommonItems(dialog: AlertDialog) = with(dialog) {
|
||||||
|
CommonDialogItems(dialog, text1, apply_btt, cancel_btt, create_account,null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getVisibilityMap(dialog: AlertDialog): Map<View, Boolean> = with(dialog) {
|
||||||
|
mapOf(
|
||||||
|
login_email_input to api.requiresEmail,
|
||||||
|
login_password_input to api.requiresPassword,
|
||||||
|
login_server_input to api.requiresServer,
|
||||||
|
login_username_input to api.requiresUsername
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setupItems(dialog: AlertDialog): Unit = with(dialog) {
|
||||||
|
login_email_input?.isVisible = api.requiresEmail
|
||||||
|
login_password_input?.isVisible = api.requiresPassword
|
||||||
|
login_server_input?.isVisible = api.requiresServer
|
||||||
|
login_username_input?.isVisible = api.requiresUsername
|
||||||
|
|
||||||
|
create_account?.isGone = api.createAccountUrl.isNullOrBlank()
|
||||||
|
create_account?.setOnClickListener {
|
||||||
|
AcraApplication.openBrowser(
|
||||||
|
api.createAccountUrl ?: return@setOnClickListener, activity
|
||||||
|
)
|
||||||
|
|
||||||
|
dismissSafe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleStoresPasswordInPlainText(dialog: AlertDialog): Unit = with(dialog) {
|
||||||
|
if (!api.storesPasswordInPlainText) return
|
||||||
|
|
||||||
|
api.getLatestLoginData()?.let { data ->
|
||||||
|
login_email_input?.setText(data.email ?: "")
|
||||||
|
login_server_input?.setText(data.server ?: "")
|
||||||
|
login_username_input?.setText(data.username ?: "")
|
||||||
|
login_password_input?.setText(data.password ?: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
145
app/src/main/res/layout/add_account_input_oauth.xml
Normal file
145
app/src/main/res/layout/add_account_input_oauth.xml
Normal 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>
|
|
@ -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>
|
|
@ -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" />-->
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue