diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 61a0634f..fc2e9131 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -217,6 +217,7 @@ dependencies { implementation("com.github.discord:OverlappingPanels:0.1.5") // Gestures implementation ("androidx.biometric:biometric:1.2.0-alpha05") // Fingerprint Authentication 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 implementation("org.mozilla:rhino:1.7.15") // run JavaScript diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt index ef74edfc..3d0bb940 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt @@ -5,7 +5,23 @@ import androidx.fragment.app.FragmentActivity interface OAuth2API : AuthAPI { val key: String val redirectUrl: String + val supportDeviceAuth: Boolean suspend fun handleRedirect(url: String) : Boolean 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, + ) } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt index 5c02e7f7..0551fe6c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt @@ -32,6 +32,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { override val redirectUrl = "anilistlogin" override val idPrefix = "anilist" override var requireLibraryRefresh = true + override val supportDeviceAuth = false override var mainUrl = "https://anilist.co" override val icon = R.drawable.ic_anilist_icon override val requiresLogin = false diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt index 7ec168da..94537ea3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt @@ -11,6 +11,7 @@ class Dropbox : OAuth2API { override val key = "zlqsamadlwydvb2" override val redirectUrl = "dropboxlogin" override val requiresLogin = true + override val supportDeviceAuth = false override val createAccountUrl: String? = null override val icon: Int diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt index 7552fe9d..00f8d00c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt @@ -21,6 +21,7 @@ class LocalList : SyncAPI { override val name = "Local" override val icon: Int = R.drawable.ic_baseline_storage_24 override val requiresLogin = false + override val supportDeviceAuth = false override val createAccountUrl: Nothing? = null override val idPrefix = "local" override var requireLibraryRefresh = true diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt index fdbe763a..4249f949 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt @@ -40,6 +40,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { private val apiUrl = "https://api.myanimelist.net" override val icon = R.drawable.mal_logo override val requiresLogin = false + override val supportDeviceAuth = false override val syncIdName = SyncIdName.MyAnimeList override var requireLibraryRefresh = true override val createAccountUrl = "$mainUrl/register.php" diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt index 08c8588b..4385fa5e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -22,6 +22,7 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.OAuth2API import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.SyncWatchType @@ -45,6 +46,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { override var name = "Simkl" override val key = "simkl-key" override val redirectUrl = "simkl" + override val supportDeviceAuth = true override val idPrefix = "simkl" override var requireLibraryRefresh = true 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( val all: String?, @@ -1045,6 +1062,44 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { 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() ?: 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() ?: 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 { val uri = url.toUri() val state = uri.getQueryParameter("state") diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt index 0e8160db..e0762cc5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.ui.result import android.content.Context +import android.graphics.Bitmap import android.util.Log import android.widget.ImageView import android.widget.TextView @@ -84,12 +85,14 @@ sealed class UiImage { ) : 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) { when (value) { is UiImage.Image -> setImageImage(value, fadeIn) is UiImage.Drawable -> setImageDrawable(value) + is UiImage.Bitmap -> setImageBitmap(value) null -> { this?.isVisible = false } @@ -107,6 +110,12 @@ fun ImageView?.setImageDrawable(value: UiImage.Drawable) { 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") fun img( url: String?, @@ -129,6 +138,10 @@ fun img(@DrawableRes drawable: Int): UiImage { return UiImage.Drawable(drawable) } +fun img(bitmap: Bitmap): UiImage { + return UiImage.Bitmap(bitmap) +} + fun txt(value: String): UiText { return UiText.DynamicString(value) } 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 27233525..1a6053ce 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 @@ -1,8 +1,9 @@ package com.lagradost.cloudstream3.ui.settings +import android.graphics.Bitmap import android.os.Bundle import android.view.View -import android.view.View.* +import android.view.View.FOCUS_DOWN import android.view.inputmethod.EditorInfo import android.widget.TextView import androidx.annotation.UiThread @@ -21,6 +22,7 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AccountManagmentBinding import com.lagradost.cloudstream3.databinding.AccountSwitchBinding import com.lagradost.cloudstream3.databinding.AddAccountInputBinding +import com.lagradost.cloudstream3.databinding.DeviceAuthBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager 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.InAppAuthAPI 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.PHONE 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.Coroutines.ioSafe 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.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.setImage +import kotlinx.coroutines.delay +import qrcode.QRCode class SettingsAccount : PreferenceFragmentCompat(), BiometricAuthenticator.BiometricAuthCallback { companion object { @@ -133,7 +142,85 @@ class SettingsAccount : PreferenceFragmentCompat(), BiometricAuthenticator.Biome try { when (api) { is OAuth2API -> { - api.authenticate(activity) + 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) + } + 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 -> { diff --git a/app/src/main/res/drawable/example_qr.png b/app/src/main/res/drawable/example_qr.png new file mode 100644 index 00000000..18decbac Binary files /dev/null and b/app/src/main/res/drawable/example_qr.png differ diff --git a/app/src/main/res/layout/device_auth.xml b/app/src/main/res/layout/device_auth.xml new file mode 100644 index 00000000..38ff1325 --- /dev/null +++ b/app/src/main/res/layout/device_auth.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + \ 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 deee5ad2..09a331ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -492,6 +492,7 @@ account Log out Log in + Auth Locally Switch account Add account Create account @@ -557,6 +558,7 @@ SDR Web Poster Image + QR Code Image Player Resolution and title Title @@ -775,4 +777,8 @@ Media Reset CloudStream Wiki + Visit %s on your smartphone or computer and enter the above code + "Can't get the device PIN code, try local authentication" + "PIN code is now expired !" + "Code expires in %1$dm %2$ds" \ No newline at end of file