feat(TV UI): Accounts PIN login support

This commit is contained in:
KingLucius 2024-06-08 10:23:16 +03:00
parent 7eec0eff02
commit b459126f58
12 changed files with 243 additions and 2 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,8 +1,9 @@
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.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
@ -21,6 +22,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 +33,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
@ -50,9 +56,12 @@ 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 kotlinx.coroutines.delay
import qrcode.QRCode
class SettingsAccount : PreferenceFragmentCompat(), BiometricAuthenticator.BiometricAuthCallback { class SettingsAccount : PreferenceFragmentCompat(), BiometricAuthenticator.BiometricAuthCallback {
companion object { companion object {
@ -133,8 +142,86 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricAuthenticator.Biome
try { try {
when (api) { when (api) {
is OAuth2API -> { is OAuth2API -> {
if (isLayout(TV or EMULATOR) && api.supportDeviceAuth && activity != null) {
val binding: DeviceAuthBinding =
DeviceAuthBinding.inflate(activity.layoutInflater, null, false)
val builder =
AlertDialog.Builder(activity)
.setView(binding.root)
builder.setNegativeButton(R.string.cancel) { _, _ -> }
builder.setPositiveButton(R.string.auth_locally) { _, _ ->
api.authenticate(activity) api.authenticate(activity)
} }
val dialog = builder.create()
ioSafe {
try {
val pinCodeData = api.getDevicePin()
if (pinCodeData == null) {
activity.runOnUiThread {
showToast(
activity.getString(R.string.device_pin_error_message)
)
}
api.authenticate(activity)
return@ioSafe
}
val qrCodeImage = QRCode.ofRoundedSquares()
.withColor(activity.colorFromAttribute(R.attr.colorPrimary))
.build(pinCodeData.verificationUrl)
.render().nativeImage() as Bitmap
activity.runOnUiThread {
dialog.show()
binding.devicePinCode.setText(txt(pinCodeData.userCode))
binding.deviceAuthMessage.setText(txt(R.string.device_pin_url_message, pinCodeData.verificationUrl))
binding.deviceAuthQrcode.setImage(
img(qrCodeImage)
)
}
var expirationCounter = pinCodeData.expiresIn
while (expirationCounter > 0) {
activity.runOnUiThread {
binding.deviceAuthValidationCounter.setText(
txt(
R.string.device_pin_counter_text, expirationCounter.div(60) , expirationCounter.rem(60)
)
)
}
if (expirationCounter.rem(pinCodeData.interval) == 0 && api.handleDeviceAuth(pinCodeData)) {
activity.runOnUiThread {
showToast(
activity.getString(R.string.authenticated_user)
.format(
api.name
)
)
dialog.dismissSafe(activity)
}
return@ioSafe
}
delay(1000)
expirationCounter--
}
activity.runOnUiThread {
showToast(
activity.getString(R.string.device_pin_expired_message)
)
dialog.dismissSafe(activity)
}
} catch (e: Exception) {
logError(e)
}
}
} else {
api.authenticate(activity)
}
}
is InAppAuthAPI -> { is InAppAuthAPI -> {
if (activity == null) return if (activity == null) return

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

@ -492,6 +492,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>
@ -557,6 +558,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>
@ -775,4 +777,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>