feat(TV UI): Accounts PIN login support (#1123)

This commit is contained in:
KingLucius 2024-06-19 17:06:08 +03:00 committed by GitHub
parent b702b7b1ec
commit afa178a63a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 286 additions and 17 deletions

View file

@ -217,6 +217,7 @@ dependencies {
implementation("com.github.discord:OverlappingPanels:0.1.5") // Gestures implementation("com.github.discord:OverlappingPanels:0.1.5") // Gestures
implementation ("androidx.biometric:biometric:1.2.0-alpha05") // Fingerprint Authentication implementation ("androidx.biometric:biometric:1.2.0-alpha05") // Fingerprint Authentication
implementation("com.github.rubensousa:previewseekbar-media3:1.1.1.0") // SeekBar Preview implementation("com.github.rubensousa:previewseekbar-media3:1.1.1.0") // SeekBar Preview
implementation("io.github.g0dkar:qrcode-kotlin:4.1.1") // QR code for PIN Auth on TV
// Extensions & Other Libs // Extensions & Other Libs
implementation("org.mozilla:rhino:1.7.15") // run JavaScript implementation("org.mozilla:rhino:1.7.15") // run JavaScript

View file

@ -5,7 +5,23 @@ import androidx.fragment.app.FragmentActivity
interface OAuth2API : AuthAPI { interface OAuth2API : AuthAPI {
val key: String val key: String
val redirectUrl: String val redirectUrl: String
val supportDeviceAuth: Boolean
suspend fun handleRedirect(url: String) : Boolean suspend fun handleRedirect(url: String) : Boolean
fun authenticate(activity: FragmentActivity?) fun authenticate(activity: FragmentActivity?)
suspend fun getDevicePin() : PinAuthData? {
return null
}
suspend fun handleDeviceAuth(pinAuthData: PinAuthData) : Boolean {
return false
}
data class PinAuthData(
val deviceCode: String,
val userCode: String,
val verificationUrl: String,
val expiresIn: Int,
val interval: Int,
)
} }

View file

@ -32,6 +32,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override val redirectUrl = "anilistlogin" override val redirectUrl = "anilistlogin"
override val idPrefix = "anilist" override val idPrefix = "anilist"
override var requireLibraryRefresh = true override var requireLibraryRefresh = true
override val supportDeviceAuth = false
override var mainUrl = "https://anilist.co" override var mainUrl = "https://anilist.co"
override val icon = R.drawable.ic_anilist_icon override val icon = R.drawable.ic_anilist_icon
override val requiresLogin = false override val requiresLogin = false

View file

@ -11,6 +11,7 @@ class Dropbox : OAuth2API {
override val key = "zlqsamadlwydvb2" override val key = "zlqsamadlwydvb2"
override val redirectUrl = "dropboxlogin" override val redirectUrl = "dropboxlogin"
override val requiresLogin = true override val requiresLogin = true
override val supportDeviceAuth = false
override val createAccountUrl: String? = null override val createAccountUrl: String? = null
override val icon: Int override val icon: Int

View file

@ -21,6 +21,7 @@ class LocalList : SyncAPI {
override val name = "Local" override val name = "Local"
override val icon: Int = R.drawable.ic_baseline_storage_24 override val icon: Int = R.drawable.ic_baseline_storage_24
override val requiresLogin = false override val requiresLogin = false
override val supportDeviceAuth = false
override val createAccountUrl: Nothing? = null override val createAccountUrl: Nothing? = null
override val idPrefix = "local" override val idPrefix = "local"
override var requireLibraryRefresh = true override var requireLibraryRefresh = true

View file

@ -40,6 +40,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
private val apiUrl = "https://api.myanimelist.net" private val apiUrl = "https://api.myanimelist.net"
override val icon = R.drawable.mal_logo override val icon = R.drawable.mal_logo
override val requiresLogin = false override val requiresLogin = false
override val supportDeviceAuth = false
override val syncIdName = SyncIdName.MyAnimeList override val syncIdName = SyncIdName.MyAnimeList
override var requireLibraryRefresh = true override var requireLibraryRefresh = true
override val createAccountUrl = "$mainUrl/register.php" override val createAccountUrl = "$mainUrl/register.php"

View file

@ -22,6 +22,7 @@ import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.OAuth2API
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.SyncWatchType
@ -45,6 +46,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
override var name = "Simkl" override var name = "Simkl"
override val key = "simkl-key" override val key = "simkl-key"
override val redirectUrl = "simkl" override val redirectUrl = "simkl"
override val supportDeviceAuth = true
override val idPrefix = "simkl" override val idPrefix = "simkl"
override var requireLibraryRefresh = true override var requireLibraryRefresh = true
override var mainUrl = "https://api.simkl.com" override var mainUrl = "https://api.simkl.com"
@ -267,6 +269,21 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
) )
} }
data class PinAuthResponse(
@JsonProperty("result") val result: String,
@JsonProperty("device_code") val deviceCode: String,
@JsonProperty("user_code") val userCode: String,
@JsonProperty("verification_url") val verificationUrl: String,
@JsonProperty("expires_in") val expiresIn: Int,
@JsonProperty("interval") val interval: Int,
)
data class PinExchangeResponse(
@JsonProperty("result") val result: String,
@JsonProperty("message") val message: String? = null,
@JsonProperty("access_token") val accessToken: String? = null,
)
// ------------------- // -------------------
data class ActivitiesResponse( data class ActivitiesResponse(
val all: String?, val all: String?,
@ -1045,6 +1062,44 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI {
return simklUrlRegex.find(url)?.groupValues?.get(1) ?: "" return simklUrlRegex.find(url)?.groupValues?.get(1) ?: ""
} }
override suspend fun getDevicePin(): OAuth2API.PinAuthData? {
val pinAuthResp = app.get(
"$mainUrl/oauth/pin?client_id=$clientId&redirect_uri=$appString://${redirectUrl}"
).parsedSafe<PinAuthResponse>() ?: return null
return OAuth2API.PinAuthData(
deviceCode = pinAuthResp.deviceCode,
userCode = pinAuthResp.userCode,
verificationUrl = pinAuthResp.verificationUrl,
expiresIn = pinAuthResp.expiresIn,
interval = pinAuthResp.interval
)
}
override suspend fun handleDeviceAuth(pinAuthData: OAuth2API.PinAuthData): Boolean {
val pinAuthResp = app.get(
"$mainUrl/oauth/pin/${pinAuthData.userCode}?client_id=$clientId"
).parsedSafe<PinExchangeResponse>() ?: return false
if (pinAuthResp.accessToken != null) {
switchToNewAccount()
setKey(accountId, SIMKL_TOKEN_KEY, pinAuthResp.accessToken)
val user = getUser()
if (user == null) {
removeKey(accountId, SIMKL_TOKEN_KEY)
switchToOldAccount()
return false
}
setKey(accountId, SIMKL_USER_KEY, user)
registerAccount()
requireLibraryRefresh = true
return true
}
return false
}
override suspend fun handleRedirect(url: String): Boolean { override suspend fun handleRedirect(url: String): Boolean {
val uri = url.toUri() val uri = url.toUri()
val state = uri.getQueryParameter("state") val state = uri.getQueryParameter("state")

View file

@ -1,6 +1,7 @@
package com.lagradost.cloudstream3.ui.result package com.lagradost.cloudstream3.ui.result
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.util.Log import android.util.Log
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
@ -84,12 +85,14 @@ sealed class UiImage {
) : UiImage() ) : UiImage()
data class Drawable(@DrawableRes val resId: Int) : UiImage() data class Drawable(@DrawableRes val resId: Int) : UiImage()
data class Bitmap(val bitmap: android.graphics.Bitmap) : UiImage()
} }
fun ImageView?.setImage(value: UiImage?, fadeIn: Boolean = true) { fun ImageView?.setImage(value: UiImage?, fadeIn: Boolean = true) {
when (value) { when (value) {
is UiImage.Image -> setImageImage(value, fadeIn) is UiImage.Image -> setImageImage(value, fadeIn)
is UiImage.Drawable -> setImageDrawable(value) is UiImage.Drawable -> setImageDrawable(value)
is UiImage.Bitmap -> setImageBitmap(value)
null -> { null -> {
this?.isVisible = false this?.isVisible = false
} }
@ -107,6 +110,12 @@ fun ImageView?.setImageDrawable(value: UiImage.Drawable) {
this.setImage(UiImage.Drawable(value.resId)) this.setImage(UiImage.Drawable(value.resId))
} }
fun ImageView?.setImageBitmap(value: UiImage.Bitmap) {
if (this == null) return
this.isVisible = true
this.setImageBitmap(value.bitmap)
}
@JvmName("imgNull") @JvmName("imgNull")
fun img( fun img(
url: String?, url: String?,
@ -129,6 +138,10 @@ fun img(@DrawableRes drawable: Int): UiImage {
return UiImage.Drawable(drawable) return UiImage.Drawable(drawable)
} }
fun img(bitmap: Bitmap): UiImage {
return UiImage.Bitmap(bitmap)
}
fun txt(value: String): UiText { fun txt(value: String): UiText {
return UiText.DynamicString(value) return UiText.DynamicString(value)
} }

View file

@ -1,12 +1,16 @@
package com.lagradost.cloudstream3.ui.settings package com.lagradost.cloudstream3.ui.settings
import android.graphics.Bitmap
import android.os.Bundle import android.os.Bundle
import android.os.CountDownTimer
import android.view.View import android.view.View
import android.view.View.* import android.view.View.FOCUS_DOWN
import android.view.inputmethod.EditorInfo 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.content.ContextCompat
import androidx.core.graphics.drawable.toBitmapOrNull
import androidx.core.view.isGone 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
@ -21,6 +25,7 @@ import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.AccountManagmentBinding import com.lagradost.cloudstream3.databinding.AccountManagmentBinding
import com.lagradost.cloudstream3.databinding.AccountSwitchBinding import com.lagradost.cloudstream3.databinding.AccountSwitchBinding
import com.lagradost.cloudstream3.databinding.AddAccountInputBinding import com.lagradost.cloudstream3.databinding.AddAccountInputBinding
import com.lagradost.cloudstream3.databinding.DeviceAuthBinding
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
@ -31,6 +36,10 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subDlAp
import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
import com.lagradost.cloudstream3.syncproviders.OAuth2API import com.lagradost.cloudstream3.syncproviders.OAuth2API
import com.lagradost.cloudstream3.ui.result.img
import com.lagradost.cloudstream3.ui.result.setImage
import com.lagradost.cloudstream3.ui.result.setText
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
@ -51,9 +60,13 @@ import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo
import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogText import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogText
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
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 com.lagradost.cloudstream3.utils.UIHelper.toPx
import qrcode.QRCode
import java.io.ByteArrayOutputStream
class SettingsAccount : PreferenceFragmentCompat(), BiometricAuthenticator.BiometricAuthCallback { class SettingsAccount : PreferenceFragmentCompat(), BiometricAuthenticator.BiometricAuthCallback {
companion object { companion object {
@ -134,7 +147,109 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricAuthenticator.Biome
try { try {
when (api) { when (api) {
is OAuth2API -> { is OAuth2API -> {
api.authenticate(activity) if (isLayout(PHONE) || !api.supportDeviceAuth) {
api.authenticate(activity)
} else if (api.supportDeviceAuth && activity != null) {
val binding: DeviceAuthBinding =
DeviceAuthBinding.inflate(activity.layoutInflater, null, false)
val builder =
AlertDialog.Builder(activity)
.setView(binding.root)
builder.apply {
setNegativeButton(R.string.cancel) { _, _ -> }
setPositiveButton(R.string.auth_locally) { _, _ ->
api.authenticate(activity)
}
}
val dialog = builder.create()
ioSafe {
try {
val pinCodeData = api.getDevicePin()
if (pinCodeData == null) {
showToast(R.string.device_pin_error_message)
api.authenticate(activity)
return@ioSafe
}
/*val logoBytes = ContextCompat.getDrawable(
activity,
R.drawable.cloud_2_solid
)?.toBitmapOrNull()?.let { bitmap ->
val csLogo = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, csLogo)
csLogo.toByteArray()
}*/
val qrCodeImage = QRCode.ofRoundedSquares()
.withColor(activity.colorFromAttribute(R.attr.textColor))
.withBackgroundColor(activity.colorFromAttribute(R.attr.primaryBlackBackground))
//.withLogo(logoBytes, 200.toPx, 200.toPx) //For later if logo needed anytime
.build(pinCodeData.verificationUrl)
.render().nativeImage() as Bitmap
activity.runOnUiThread {
dialog.show()
binding.apply {
devicePinCode.setText(txt(pinCodeData.userCode))
deviceAuthMessage.setText(
txt(
R.string.device_pin_url_message,
pinCodeData.verificationUrl
)
)
deviceAuthQrcode.setImage(
img(qrCodeImage)
)
}
val expirationMillis =
pinCodeData.expiresIn.times(1000).toLong()
object : CountDownTimer(expirationMillis, 1000) {
override fun onTick(millisUntilFinished: Long) {
val secondsUntilFinished =
millisUntilFinished.div(1000).toInt()
binding.deviceAuthValidationCounter.setText(
txt(
R.string.device_pin_counter_text,
secondsUntilFinished.div(60),
secondsUntilFinished.rem(60)
)
)
ioSafe {
if (secondsUntilFinished.rem(pinCodeData.interval) == 0 && api.handleDeviceAuth(pinCodeData)) {
showToast(
txt(
R.string.authenticated_user,
api.name
)
)
dialog.dismissSafe(activity)
cancel()
}
}
}
override fun onFinish() {
showToast(R.string.device_pin_expired_message)
dialog.dismissSafe(activity)
}
}.start()
}
} catch (e: Exception) {
logError(e)
}
}
}
} }
is InAppAuthAPI -> { is InAppAuthAPI -> {
@ -227,23 +342,15 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricAuthenticator.Biome
server = if (api.requiresServer) binding.loginServerInput.text?.toString() else null, server = if (api.requiresServer) binding.loginServerInput.text?.toString() else null,
) )
ioSafe { ioSafe {
val isSuccessful = try { try {
api.login(loginData) showToast(
txt(
if (api.login(loginData)) R.string.authenticated_user else R.string.authenticated_user_fail,
api.name
)
)
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
false
}
activity.runOnUiThread {
try {
showToast(
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.dismissSafe(activity)

View file

@ -0,0 +1,8 @@
<vector xmlns:tools="http://schemas.android.com/tools" android:name="vector"
android:width="200dp" android:height="200dp" android:viewportWidth="283"
android:viewportHeight="283" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:name="path"
android:pathData="M 245.05 148.63 C 242.249 148.627 239.463 149.052 236.79 149.89 C 235.151 141.364 230.698 133.63 224.147 127.931 C 217.597 122.233 209.321 118.893 200.65 118.45 C 195.913 105.431 186.788 94.458 174.851 87.427 C 162.914 80.396 148.893 77.735 135.21 79.905 C 121.527 82.074 109.017 88.941 99.84 99.32 C 89.871 95.945 79.051 96.024 69.133 99.545 C 59.215 103.065 50.765 109.826 45.155 118.73 C 39.545 127.634 37.094 138.174 38.2 148.64 L 37.94 148.64 C 30.615 148.64 23.582 151.553 18.403 156.733 C 13.223 161.912 10.31 168.945 10.31 176.27 C 10.31 183.595 13.223 190.628 18.403 195.807 C 23.582 200.987 30.615 203.9 37.94 203.9 L 245.05 203.9 C 252.375 203.9 259.408 200.987 264.587 195.807 C 269.767 190.628 272.68 183.595 272.68 176.27 C 272.68 168.945 269.767 161.912 264.587 156.733 C 259.408 151.553 252.375 148.64 245.05 148.64 Z"
android:fillColor="?attr/textColor" android:strokeWidth="1"
tools:ignore="VectorPath"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical">
<TextView
android:id="@+id/device_pin_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="10dp"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:textColor="?attr/colorPrimary"
android:textSize="30sp"
android:textStyle="bold"
tools:text="YJTSKL" />
<TextView
android:id="@+id/device_auth_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_gravity="center_horizontal"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:textColor="?attr/textColor"
android:textSize="17sp"
tools:text="Visit simkl.com/pin on your smartphone or computer and enter the above code" />
<ImageView
android:id="@+id/device_auth_qrcode"
android:layout_marginTop="20dp"
android:layout_width="200dp"
android:layout_height="200dp"
android:scaleType="fitCenter"
android:adjustViewBounds="true"
android:layout_gravity="center_horizontal"
android:visibility="visible"
android:background="?attr/primaryGrayBackground"
android:contentDescription="@string/qr_image"
tools:visibility="visible"
tools:src="@drawable/example_qr" />
<TextView
android:id="@+id/device_auth_validation_counter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_gravity="center_horizontal"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
android:textColor="?attr/textColor"
android:textSize="17sp"
tools:text="Code expires in 13m 2s" />
</LinearLayout>

View file

@ -497,6 +497,7 @@
<string name="account">account</string> <string name="account">account</string>
<string name="logout">Log out</string> <string name="logout">Log out</string>
<string name="login">Log in</string> <string name="login">Log in</string>
<string name="auth_locally">Auth Locally</string>
<string name="switch_account">Switch account</string> <string name="switch_account">Switch account</string>
<string name="add_account">Add account</string> <string name="add_account">Add account</string>
<string name="create_account">Create account</string> <string name="create_account">Create account</string>
@ -562,6 +563,7 @@
<string name="quality_sdr">SDR</string> <string name="quality_sdr">SDR</string>
<string name="quality_webrip">Web</string> <string name="quality_webrip">Web</string>
<string name="poster_image">Poster Image</string> <string name="poster_image">Poster Image</string>
<string name="qr_image">QR Code Image</string>
<string name="category_player">Player</string> <string name="category_player">Player</string>
<string name="resolution_and_title">Resolution and title</string> <string name="resolution_and_title">Resolution and title</string>
<string name="title">Title</string> <string name="title">Title</string>
@ -780,4 +782,8 @@
<string name="custom_media_singluar">Media</string> <string name="custom_media_singluar">Media</string>
<string name="reset_btn">Reset</string> <string name="reset_btn">Reset</string>
<string name="cs3wiki">CloudStream Wiki</string> <string name="cs3wiki">CloudStream Wiki</string>
<string name="device_pin_url_message">Visit <b>%s</b> on your smartphone or computer and enter the above code</string>
<string name="device_pin_error_message">Can\'t get the device PIN code, try local authentication</string>
<string name="device_pin_expired_message">PIN code is now expired !</string>
<string name="device_pin_counter_text">Code expires in %1$dm %2$ds</string>
</resources> </resources>