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