diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0bd56fe7..502a3ec0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -2,6 +2,8 @@ import com.android.build.gradle.api.BaseVariantOutput
import org.jetbrains.dokka.gradle.DokkaTask
import java.io.ByteArrayOutputStream
import java.net.URL
+import java.util.Properties
+import java.io.FileInputStream
plugins {
id("com.android.application")
@@ -19,11 +21,14 @@ fun String.execute() = ByteArrayOutputStream().use { baot ->
workingDir = projectDir
commandLine = this@execute.split(Regex("\\s"))
standardOutput = baot
- }.exitValue == 0)
+ }.exitValue == 0)
String(baot.toByteArray()).trim()
else null
}
+val localProperties = Properties()
+localProperties.load(FileInputStream(rootProject.file("local.properties")))
+
android {
testOptions {
unitTests.isReturnDefaultValues = true
@@ -79,7 +84,20 @@ android {
debug {
isDebuggable = true
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")
@@ -225,8 +243,26 @@ dependencies {
// color pallette for images -> colors
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) {
archiveClassifier.set("sources")
from(android.sourceSets.getByName("main").java.srcDirs) //full sources
diff --git a/app/src/debug/res/drawable/ic_baseline_add_to_drive_24.xml b/app/src/debug/res/drawable/ic_baseline_add_to_drive_24.xml
new file mode 100644
index 00000000..6a717cb3
--- /dev/null
+++ b/app/src/debug/res/drawable/ic_baseline_add_to_drive_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 563c82f8..a75b9069 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -159,6 +159,17 @@
android:pathPrefix="/"
android:scheme="https" />
+
+
+
+
+
+
+
+
+
+
+
(
- malApi, aniListApi
+ malApi, aniListApi, googleDriveApi
)
// this needs init with context and can be accessed in settings
val accountManagers
get() = listOf(
- malApi, aniListApi, openSubtitlesApi, //nginxApi
+ malApi, aniListApi, openSubtitlesApi, googleDriveApi //, nginxApi
)
// used for active syncing
@@ -33,8 +35,16 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi)
)
+ // used for active backup
+ val BackupApis
+ get() = listOf>(
+ googleDriveApi
+ )
+
val inAppAuths
- get() = listOf(openSubtitlesApi)//, nginxApi)
+ get() = listOf(
+ openSubtitlesApi, googleDriveApi//, nginxApi
+ )
val subtitleProviders
get() = listOf(
@@ -89,6 +99,12 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
// int array of all accounts indexes
private val accountsKey get() = "${idPrefix}_accounts"
+
+ // runs on startup
+ @WorkerThread
+ open suspend fun initialize() {
+ }
+
protected fun removeAccountKeys() {
removeKeys(accountId)
val accounts = getAccounts()?.toMutableList() ?: mutableListOf()
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt
new file mode 100644
index 00000000..b184117b
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt
@@ -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 {
+ 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()
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt
index 8b6fdf46..e17e7ccc 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt
@@ -35,11 +35,6 @@ abstract class InAppAuthAPIManager(defIndex: Int) : AccountManager(defIndex), In
override val storesPasswordInPlainText = true
override val requiresLogin = true
- // runs on startup
- @WorkerThread
- open suspend fun initialize() {
- }
-
override fun logOut() {
throw NotImplementedError()
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt
new file mode 100644
index 00000000..5af8ff51
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppOAuth2API.kt
@@ -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 storeValue(key: K, value: T) = AcraApplication.setKey(
+ accountId, key.value, value
+ )
+
+ protected fun clearValue(key: K) = AcraApplication.removeKey(
+ accountId, key.value
+ )
+
+ protected inline fun getValue(key: K) = AcraApplication.getKey(
+ accountId, key.value
+ )
+
+ override val requiresLogin = true
+ override val createAccountUrl = null
+
+ override fun logOut() {
+ K.values().forEach { clearValue(it) }
+ removeAccountKeys()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt
new file mode 100644
index 00000000..10ab64b4
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/GoogleDriveApi.kt
@@ -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 {
+ /////////////////////////////////////////
+ /////////////////////////////////////////
+ // 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(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(K.LOGIN_DATA)
+ }
+
+ /////////////////////////////////////////
+ /////////////////////////////////////////
+ // BackupAPI implementation
+ override fun Context.mergeBackup(incomingData: String) {
+ val currentData = getBackup()
+ val newData = DataStore.mapper.readValue(incomingData)
+
+ getRedundantKeys(currentData, newData).forEach {
+ removeKey(it)
+ }
+
+
+ restore(
+ newData,
+ restoreSettings = true,
+ restoreDataStore = true
+ )
+
+ }
+
+ // 🤮
+ private fun getRedundantKeys(
+ old: BackupUtils.BackupFile,
+ new: BackupUtils.BackupFile
+ ): List = 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?, new: Map?): Array =
+ 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(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
+ )
+ }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt
index 1ef3cb55..b6d33f81 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt
@@ -2,38 +2,31 @@ package com.lagradost.cloudstream3.ui.settings
import android.os.Bundle
import android.view.View
-import android.view.View.*
-import android.view.inputmethod.EditorInfo
import android.widget.TextView
import androidx.annotation.UiThread
import androidx.appcompat.app.AlertDialog
-import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentActivity
import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView
-import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
-import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.syncproviders.AccountManager
+import com.lagradost.cloudstream3.syncproviders.*
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.googleDriveApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi
-import com.lagradost.cloudstream3.syncproviders.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.isTvSettings
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
-import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
+import com.lagradost.cloudstream3.ui.settings.helpers.settings.account.InAppAuthDialogBuilder
+import com.lagradost.cloudstream3.ui.settings.helpers.settings.account.InAppOAuth2DialogBuilder
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.setImage
import kotlinx.android.synthetic.main.account_managment.*
import kotlinx.android.synthetic.main.account_switch.*
-import kotlinx.android.synthetic.main.add_account_input.*
class SettingsAccount : PreferenceFragmentCompat() {
companion object {
@@ -108,122 +101,9 @@ class SettingsAccount : PreferenceFragmentCompat() {
fun addAccount(activity: FragmentActivity?, api: AccountManager) {
try {
when (api) {
- is OAuth2API -> {
- api.authenticate(activity)
- }
- 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)
- }
- }
+ is InAppOAuth2API -> InAppOAuth2DialogBuilder(api, activity).open()
+ is OAuth2API -> api.authenticate(activity)
+ is InAppAuthAPI -> InAppAuthDialogBuilder(api, activity).open()
else -> {
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.anilist_key to aniListApi,
R.string.opensubtitles_key to openSubtitlesApi,
+ R.string.gdrive_key to googleDriveApi
)
for ((key, api) in syncApis) {
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt
new file mode 100644
index 00000000..741e2817
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/DialogBuilder.kt
@@ -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 AlertDialog.getCommonItem(view: T?): T? {
+ return findViewById(view?.id ?: return null)
+ }
+ }
+
+
+ abstract fun getCommonItems(dialog: AlertDialog): CommonDialogItems
+ abstract fun getVisibilityMap(dialog: AlertDialog): Map
+ 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
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt
new file mode 100644
index 00000000..47ec545e
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppAuthDialogBuilder.kt
@@ -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 = 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 ?: "")
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt
new file mode 100644
index 00000000..1d93d682
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/helpers/settings/account/InAppOAuth2DialogBuilder.kt
@@ -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 = 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()
+ }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt
index 2318fda6..002d617c 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt
@@ -18,6 +18,7 @@ import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.plugins.PLUGINS_KEY
import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL
+import com.lagradost.cloudstream3.syncproviders.InAppOAuth2APIManager
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_TOKEN_KEY
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_UNIXTIME_KEY
@@ -60,6 +61,7 @@ object BackupUtils {
MAL_CACHED_LIST,
MAL_UNIXTIME_KEY,
MAL_USER_KEY,
+ InAppOAuth2APIManager.K.TOKEN.value,
// The plugins themselves are not backed up
PLUGINS_KEY,
@@ -71,7 +73,7 @@ object BackupUtils {
/** false if blacklisted key */
private fun String.isTransferable(): Boolean {
- return !nonTransferableKeys.contains(this)
+ return !nonTransferableKeys.any { this.contains(it) }
}
private var restoreFileSelector: ActivityResultLauncher>? = null
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt
index e1cedd39..06267048 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt
@@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.syncproviders.AccountManager
const val DOWNLOAD_HEADER_CACHE = "download_header_cache"
@@ -20,7 +21,9 @@ const val PREFERENCES_NAME = "rebuild_preference"
object DataStore {
val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule())
- .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+ .configure(DeserializationFeature.USE_LONG_FOR_INTS, true)
+ .build()
private fun getPreferences(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
@@ -80,6 +83,8 @@ object DataStore {
val editor: SharedPreferences.Editor = prefs.edit()
editor.remove(path)
editor.apply()
+
+ AccountManager.BackupApis.forEach { it.addToQueue() }
}
} catch (e: Exception) {
logError(e)
@@ -99,6 +104,8 @@ object DataStore {
val editor: SharedPreferences.Editor = getSharedPrefs().edit()
editor.putString(path, mapper.writeValueAsString(value))
editor.apply()
+
+ AccountManager.BackupApis.forEach { it.addToQueue() }
} catch (e: Exception) {
logError(e)
}
diff --git a/app/src/main/res/layout/add_account_input_oauth.xml b/app/src/main/res/layout/add_account_input_oauth.xml
new file mode 100644
index 00000000..efc929f2
--- /dev/null
+++ b/app/src/main/res/layout/add_account_input_oauth.xml
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ac76e243..2c37fcce 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -446,9 +446,12 @@
Poster title location
Put the title under the poster
+ Plugins
+ Remote Sync
anilist_key
mal_key
opensubtitles_key
+ gdrive_key
nginx_key
password123
MyCoolUsername
@@ -457,6 +460,9 @@
MyCoolSite
example.com
Language code (en)
+ cloudstreamapp-sync-file
+ OAuth Client ID
+ OAuth Client Secret