mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
Implement Yandex SmartCaptcha support
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
+246
@@ -0,0 +1,246 @@
|
|||||||
|
package ru.fromchat.ui.auth.captcha
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.http.SslError
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.SslErrorHandler
|
||||||
|
import android.webkit.WebResourceError
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
|
||||||
|
private const val SMARTCAPTCHA_WEBVIEW_BASE = "https://smartcaptcha.cloud.yandex.ru/webview"
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
actual fun SmartCaptchaWebView(
|
||||||
|
sitekey: String,
|
||||||
|
languageTag: String,
|
||||||
|
modifier: Modifier,
|
||||||
|
onToken: (String) -> Unit,
|
||||||
|
onReady: () -> Unit,
|
||||||
|
onChallengeVisible: () -> Unit,
|
||||||
|
onChallengeHidden: () -> Unit,
|
||||||
|
onError: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val onTokenState = rememberUpdatedState(onToken)
|
||||||
|
val onReadyState = rememberUpdatedState(onReady)
|
||||||
|
val onChallengeVisibleState = rememberUpdatedState(onChallengeVisible)
|
||||||
|
val onChallengeHiddenState = rememberUpdatedState(onChallengeHidden)
|
||||||
|
val onErrorState = rememberUpdatedState(onError)
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
val backgroundArgb = MaterialTheme.colorScheme.surfaceContainer.toArgb()
|
||||||
|
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
val instanceId = remember { Integer.toHexString(System.identityHashCode(Any())) }
|
||||||
|
|
||||||
|
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
|
||||||
|
val captchaUrl = remember(sitekey, lang) {
|
||||||
|
"$SMARTCAPTCHA_WEBVIEW_BASE?sitekey=${sitekey.trim()}&hl=$lang"
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(instanceId) {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"WebView compose enter id=$instanceId sitekey=${SmartCaptchaLog.redactKey(sitekey)} " +
|
||||||
|
"lang=$lang languageTag=$languageTag url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
|
||||||
|
)
|
||||||
|
onDispose {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "WebView compose dispose id=$instanceId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val bridge = remember {
|
||||||
|
val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
object {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onGetToken(token: String) {
|
||||||
|
val cleaned = token.trim()
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"JS onGetToken id=$instanceId ${SmartCaptchaLog.redactToken(cleaned)}",
|
||||||
|
)
|
||||||
|
mainHandler.post {
|
||||||
|
if (cleaned.isNotEmpty()) {
|
||||||
|
onTokenState.value(cleaned)
|
||||||
|
} else {
|
||||||
|
Logger.w(SmartCaptchaLog.TAG, "JS onGetToken empty id=$instanceId")
|
||||||
|
onErrorState.value("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onChallengeVisible() {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "JS onChallengeVisible id=$instanceId")
|
||||||
|
mainHandler.post { onChallengeVisibleState.value() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onChallengeHidden() {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "JS onChallengeHidden id=$instanceId")
|
||||||
|
mainHandler.post { onChallengeHiddenState.value() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(webView, lifecycleOwner) {
|
||||||
|
val wv = webView ?: return@DisposableEffect onDispose { }
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
Logger.d(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"lifecycle $event id=$instanceId url=${SmartCaptchaLog.shortUrl(wv.url)} " +
|
||||||
|
"progress=${wv.progress}",
|
||||||
|
)
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_PAUSE -> wv.onPause()
|
||||||
|
Lifecycle.Event.ON_RESUME -> wv.onResume()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||||
|
wv.onResume()
|
||||||
|
}
|
||||||
|
onDispose {
|
||||||
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
wv.onPause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "AndroidView.factory id=$instanceId")
|
||||||
|
WebView(context).apply {
|
||||||
|
setBackgroundColor(backgroundArgb)
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
addJavascriptInterface(bridge, "NativeClient")
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun shouldOverrideUrlLoading(
|
||||||
|
view: WebView?,
|
||||||
|
request: WebResourceRequest?,
|
||||||
|
): Boolean {
|
||||||
|
val url = request?.url?.toString()
|
||||||
|
val host = request?.url?.host?.lowercase().orEmpty()
|
||||||
|
val block = host.isNotEmpty() &&
|
||||||
|
!host.endsWith("yandex.ru") &&
|
||||||
|
!host.endsWith("yandex.com") &&
|
||||||
|
!host.endsWith("yandex.net")
|
||||||
|
Logger.d(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"shouldOverrideUrlLoading id=$instanceId block=$block " +
|
||||||
|
"host=$host url=${SmartCaptchaLog.shortUrl(url)}",
|
||||||
|
)
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"onPageStarted id=$instanceId url=${SmartCaptchaLog.shortUrl(url)}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"onPageFinished id=$instanceId progress=${view?.progress} " +
|
||||||
|
"url=${SmartCaptchaLog.shortUrl(url)}",
|
||||||
|
)
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
onReadyState.value()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedError(
|
||||||
|
view: WebView?,
|
||||||
|
request: WebResourceRequest?,
|
||||||
|
error: WebResourceError?,
|
||||||
|
) {
|
||||||
|
Logger.w(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"onReceivedError id=$instanceId main=${request?.isForMainFrame} " +
|
||||||
|
"code=${error?.errorCode} desc=${error?.description} " +
|
||||||
|
"url=${SmartCaptchaLog.shortUrl(request?.url?.toString())}",
|
||||||
|
)
|
||||||
|
if (request?.isForMainFrame == true) {
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
onErrorState.value(error?.description?.toString().orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedHttpError(
|
||||||
|
view: WebView?,
|
||||||
|
request: WebResourceRequest?,
|
||||||
|
errorResponse: WebResourceResponse?,
|
||||||
|
) {
|
||||||
|
Logger.w(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"onReceivedHttpError id=$instanceId main=${request?.isForMainFrame} " +
|
||||||
|
"status=${errorResponse?.statusCode} " +
|
||||||
|
"url=${SmartCaptchaLog.shortUrl(request?.url?.toString())}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedSslError(
|
||||||
|
view: WebView?,
|
||||||
|
handler: SslErrorHandler?,
|
||||||
|
error: SslError?,
|
||||||
|
) {
|
||||||
|
Logger.e(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"onReceivedSslError id=$instanceId primary=${error?.primaryError} " +
|
||||||
|
"url=${SmartCaptchaLog.shortUrl(error?.url)}",
|
||||||
|
)
|
||||||
|
handler?.cancel()
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
onErrorState.value("SSL error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"loadUrl id=$instanceId url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
|
||||||
|
)
|
||||||
|
loadUrl(captchaUrl)
|
||||||
|
webView = this
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
update = { wv ->
|
||||||
|
wv.setBackgroundColor(backgroundArgb)
|
||||||
|
webView = wv
|
||||||
|
},
|
||||||
|
onRelease = { wv ->
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"AndroidView.onRelease id=$instanceId url=${SmartCaptchaLog.shortUrl(wv.url)}",
|
||||||
|
)
|
||||||
|
wv.removeJavascriptInterface("NativeClient")
|
||||||
|
wv.stopLoading()
|
||||||
|
wv.destroy()
|
||||||
|
if (webView === wv) webView = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -55,6 +55,9 @@
|
|||||||
<string name="auth_step_yandex_title">Войдите через Яндекс ID</string>
|
<string name="auth_step_yandex_title">Войдите через Яндекс ID</string>
|
||||||
<string name="auth_step_yandex_body">Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. От Яндекса мы получаем только email — и мы его не сохраняем: вход нужен лишь для защиты.</string>
|
<string name="auth_step_yandex_body">Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. От Яндекса мы получаем только email — и мы его не сохраняем: вход нужен лишь для защиты.</string>
|
||||||
<string name="auth_step_yandex_cta">Продолжить через Яндекс ID</string>
|
<string name="auth_step_yandex_cta">Продолжить через Яндекс ID</string>
|
||||||
|
<string name="auth_captcha_title">Быстрая проверка</string>
|
||||||
|
<string name="auth_captcha_body">Подтвердите, что вы человек, чтобы продолжить создание аккаунта.</string>
|
||||||
|
<string name="auth_captcha_failed">Не удалось пройти проверку. Попробуйте ещё раз.</string>
|
||||||
<string name="auth_yandex_webview_title">Яндекс ID</string>
|
<string name="auth_yandex_webview_title">Яндекс ID</string>
|
||||||
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
|
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
|
||||||
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
|
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
|
||||||
|
|||||||
@@ -62,6 +62,9 @@
|
|||||||
<string name="auth_step_yandex_title">Sign in with Yandex ID</string>
|
<string name="auth_step_yandex_title">Sign in with Yandex ID</string>
|
||||||
<string name="auth_step_yandex_body">This helps us fight malicious bots and meet Russian legal requirements. From Yandex we only get your email — and we don’t store it; sign-in is only used for security reasons.</string>
|
<string name="auth_step_yandex_body">This helps us fight malicious bots and meet Russian legal requirements. From Yandex we only get your email — and we don’t store it; sign-in is only used for security reasons.</string>
|
||||||
<string name="auth_step_yandex_cta">Continue with Yandex ID</string>
|
<string name="auth_step_yandex_cta">Continue with Yandex ID</string>
|
||||||
|
<string name="auth_captcha_title">Quick check</string>
|
||||||
|
<string name="auth_captcha_body">Confirm you’re human to continue creating your account.</string>
|
||||||
|
<string name="auth_captcha_failed">Captcha verification failed. Please try again.</string>
|
||||||
<string name="auth_yandex_webview_title">Yandex ID</string>
|
<string name="auth_yandex_webview_title">Yandex ID</string>
|
||||||
<string name="auth_yandex_client_mismatch">This server returned an unexpected Yandex app id. Update the app or contact support.</string>
|
<string name="auth_yandex_client_mismatch">This server returned an unexpected Yandex app id. Update the app or contact support.</string>
|
||||||
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
|
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ import ru.fromchat.api.schema.user.auth.CheckAuthResponse
|
|||||||
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
|
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
|
||||||
import ru.fromchat.api.schema.user.auth.LoginResponse
|
import ru.fromchat.api.schema.user.auth.LoginResponse
|
||||||
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
||||||
|
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
|
||||||
import ru.fromchat.api.schema.user.auth.YandexExchangeRequest
|
import ru.fromchat.api.schema.user.auth.YandexExchangeRequest
|
||||||
import ru.fromchat.api.schema.user.auth.YandexExchangeResponse
|
import ru.fromchat.api.schema.user.auth.YandexExchangeResponse
|
||||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
@@ -495,6 +496,8 @@ object ApiClient {
|
|||||||
data class NeedsRegister(
|
data class NeedsRegister(
|
||||||
val yandexRequired: Boolean,
|
val yandexRequired: Boolean,
|
||||||
val yandex: YandexOAuthParams?,
|
val yandex: YandexOAuthParams?,
|
||||||
|
val captchaRequired: Boolean,
|
||||||
|
val captcha: SmartCaptchaParams?,
|
||||||
) : AuthPasswordStepOutcome
|
) : AuthPasswordStepOutcome
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,10 +515,26 @@ object ApiClient {
|
|||||||
.body<JsonObject>()
|
.body<JsonObject>()
|
||||||
val status = raw["status"]?.jsonPrimitive?.contentOrNull
|
val status = raw["status"]?.jsonPrimitive?.contentOrNull
|
||||||
return when (status) {
|
return when (status) {
|
||||||
"needs_register" -> AuthPasswordStepOutcome.NeedsRegister(
|
"needs_register" -> {
|
||||||
yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true,
|
val yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true
|
||||||
yandex = raw["yandex"]?.let { json.decodeFromJsonElement(YandexOAuthParams.serializer(), it) },
|
val captchaRequired = raw["captcha_required"]?.jsonPrimitive?.booleanOrNull == true
|
||||||
)
|
val captcha = raw["captcha"]?.let {
|
||||||
|
json.decodeFromJsonElement(SmartCaptchaParams.serializer(), it)
|
||||||
|
}
|
||||||
|
ru.fromchat.Logger.i(
|
||||||
|
"SmartCaptcha",
|
||||||
|
"authPasswordStep needs_register yandexRequired=$yandexRequired " +
|
||||||
|
"captchaRequired=$captchaRequired " +
|
||||||
|
"hasCaptchaObject=${captcha != null} " +
|
||||||
|
"clientKeyLen=${captcha?.client_key?.length ?: 0}",
|
||||||
|
)
|
||||||
|
AuthPasswordStepOutcome.NeedsRegister(
|
||||||
|
yandexRequired = yandexRequired,
|
||||||
|
yandex = raw["yandex"]?.let { json.decodeFromJsonElement(YandexOAuthParams.serializer(), it) },
|
||||||
|
captchaRequired = captchaRequired,
|
||||||
|
captcha = captcha,
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> AuthPasswordStepOutcome.LoggedIn(json.decodeFromJsonElement(LoginResponse.serializer(), raw))
|
else -> AuthPasswordStepOutcome.LoggedIn(json.decodeFromJsonElement(LoginResponse.serializer(), raw))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,18 @@ data class YandexOAuthParams(
|
|||||||
val scope: String,
|
val scope: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SmartCaptchaParams(
|
||||||
|
val client_key: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AuthNeedsRegisterResponse(
|
data class AuthNeedsRegisterResponse(
|
||||||
val status: String,
|
val status: String,
|
||||||
val yandex_required: Boolean = false,
|
val yandex_required: Boolean = false,
|
||||||
val yandex: YandexOAuthParams? = null,
|
val yandex: YandexOAuthParams? = null,
|
||||||
|
val captcha_required: Boolean = false,
|
||||||
|
val captcha: SmartCaptchaParams? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -70,4 +77,5 @@ data class RegisterConfirmRequest(
|
|||||||
val confirm_password: String,
|
val confirm_password: String,
|
||||||
val bio: String? = null,
|
val bio: String? = null,
|
||||||
val registration_proof: String? = null,
|
val registration_proof: String? = null,
|
||||||
|
val captcha_token: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ import ru.fromchat.legal.DocumentScreen
|
|||||||
import ru.fromchat.legal.DocumentType
|
import ru.fromchat.legal.DocumentType
|
||||||
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
||||||
import ru.fromchat.ui.auth.AuthScreen
|
import ru.fromchat.ui.auth.AuthScreen
|
||||||
|
import ru.fromchat.ui.auth.captcha.SmartCaptchaNav
|
||||||
|
import ru.fromchat.ui.auth.captcha.SmartCaptchaScreen
|
||||||
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
|
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
|
||||||
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
|
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
|
||||||
import ru.fromchat.ui.calls.CallOverlay
|
import ru.fromchat.ui.calls.CallOverlay
|
||||||
@@ -466,6 +468,10 @@ fun App(
|
|||||||
YandexOAuthScreen()
|
YandexOAuthScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(SmartCaptchaNav.ROUTE) {
|
||||||
|
SmartCaptchaScreen()
|
||||||
|
}
|
||||||
|
|
||||||
composable("chat") {
|
composable("chat") {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
sharedTransitionScope = this@SharedTransitionLayout,
|
sharedTransitionScope = this@SharedTransitionLayout,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package ru.fromchat.ui.auth
|
package ru.fromchat.ui.auth
|
||||||
|
|
||||||
|
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
|
||||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Survives [AuthScreen] leaving composition when navigating to the Yandex OAuth route.
|
* Survives [AuthScreen] leaving composition when navigating to Yandex OAuth / SmartCaptcha routes.
|
||||||
* Cleared on welcome / successful auth / explicit reset to username.
|
* Cleared on welcome / successful auth / explicit reset to username.
|
||||||
*/
|
*/
|
||||||
internal object AuthRegisterDraft {
|
internal object AuthRegisterDraft {
|
||||||
@@ -14,7 +15,10 @@ internal object AuthRegisterDraft {
|
|||||||
var bio: String = ""
|
var bio: String = ""
|
||||||
var yandexRequired: Boolean = false
|
var yandexRequired: Boolean = false
|
||||||
var yandexParams: YandexOAuthParams? = null
|
var yandexParams: YandexOAuthParams? = null
|
||||||
|
var captchaRequired: Boolean = false
|
||||||
|
var captchaParams: SmartCaptchaParams? = null
|
||||||
var registrationProof: String? = null
|
var registrationProof: String? = null
|
||||||
|
var captchaToken: String? = null
|
||||||
var page: Int = 0
|
var page: Int = 0
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
@@ -25,7 +29,10 @@ internal object AuthRegisterDraft {
|
|||||||
bio = ""
|
bio = ""
|
||||||
yandexRequired = false
|
yandexRequired = false
|
||||||
yandexParams = null
|
yandexParams = null
|
||||||
|
captchaRequired = false
|
||||||
|
captchaParams = null
|
||||||
registrationProof = null
|
registrationProof = null
|
||||||
|
captchaToken = null
|
||||||
page = 0
|
page = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,20 +20,24 @@ import io.ktor.client.plugins.ClientRequestException
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.crypto.IdentityKeyManager
|
import ru.fromchat.api.crypto.IdentityKeyManager
|
||||||
import ru.fromchat.api.local.cache.CacheContext
|
|
||||||
import ru.fromchat.api.local.db.clearAccountCacheOnLogout
|
|
||||||
import ru.fromchat.api.instance.ServerProbeResult
|
import ru.fromchat.api.instance.ServerProbeResult
|
||||||
import ru.fromchat.api.instance.probeServer
|
import ru.fromchat.api.instance.probeServer
|
||||||
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
|
import ru.fromchat.api.local.db.clearAccountCacheOnLogout
|
||||||
import ru.fromchat.api.schema.core.ErrorResponse
|
import ru.fromchat.api.schema.core.ErrorResponse
|
||||||
import ru.fromchat.api.schema.user.auth.LoginResponse
|
import ru.fromchat.api.schema.user.auth.LoginResponse
|
||||||
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
||||||
|
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
|
||||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
import ru.fromchat.change_server
|
import ru.fromchat.change_server
|
||||||
import ru.fromchat.config.Settings
|
import ru.fromchat.config.Settings
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
import ru.fromchat.ui.auth.captcha.SmartCaptchaLog
|
||||||
|
import ru.fromchat.ui.auth.captcha.SmartCaptchaNav
|
||||||
import ru.fromchat.ui.auth.register.confirmPasswordStepPage
|
import ru.fromchat.ui.auth.register.confirmPasswordStepPage
|
||||||
import ru.fromchat.ui.auth.register.profileStepPage
|
import ru.fromchat.ui.auth.register.profileStepPage
|
||||||
import ru.fromchat.ui.auth.yandex.yandexIdStepPage
|
import ru.fromchat.ui.auth.yandex.yandexIdStepPage
|
||||||
@@ -58,6 +62,8 @@ internal sealed interface PasswordStepResult {
|
|||||||
data class NeedsRegister(
|
data class NeedsRegister(
|
||||||
val yandexRequired: Boolean,
|
val yandexRequired: Boolean,
|
||||||
val yandex: YandexOAuthParams?,
|
val yandex: YandexOAuthParams?,
|
||||||
|
val captchaRequired: Boolean,
|
||||||
|
val captcha: SmartCaptchaParams?,
|
||||||
) : PasswordStepResult
|
) : PasswordStepResult
|
||||||
data class WrongPassword(val message: String) : PasswordStepResult
|
data class WrongPassword(val message: String) : PasswordStepResult
|
||||||
data class RateLimited(val message: String) : PasswordStepResult
|
data class RateLimited(val message: String) : PasswordStepResult
|
||||||
@@ -120,10 +126,20 @@ internal suspend fun authPasswordStep(
|
|||||||
PasswordStepResult.LoginSuccess
|
PasswordStepResult.LoginSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> PasswordStepResult.NeedsRegister(
|
is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> {
|
||||||
yandexRequired = outcome.yandexRequired,
|
Logger.i(
|
||||||
yandex = outcome.yandex,
|
SmartCaptchaLog.TAG,
|
||||||
)
|
"password step needs_register yandexRequired=${outcome.yandexRequired} " +
|
||||||
|
"captchaRequired=${outcome.captchaRequired} " +
|
||||||
|
"clientKey=${SmartCaptchaLog.redactKey(outcome.captcha?.client_key)}",
|
||||||
|
)
|
||||||
|
PasswordStepResult.NeedsRegister(
|
||||||
|
yandexRequired = outcome.yandexRequired,
|
||||||
|
yandex = outcome.yandex,
|
||||||
|
captchaRequired = outcome.captchaRequired,
|
||||||
|
captcha = outcome.captcha,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: ClientRequestException) {
|
} catch (e: ClientRequestException) {
|
||||||
when (e.response.status.value) {
|
when (e.response.status.value) {
|
||||||
@@ -147,8 +163,15 @@ internal suspend fun register(
|
|||||||
password: String,
|
password: String,
|
||||||
bio: String,
|
bio: String,
|
||||||
registrationProof: String?,
|
registrationProof: String?,
|
||||||
|
captchaToken: String?,
|
||||||
unexpectedError: String,
|
unexpectedError: String,
|
||||||
) = try {
|
) = try {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"register confirm start username=${username.trim()} " +
|
||||||
|
"hasRegistrationProof=${!registrationProof.isNullOrBlank()} " +
|
||||||
|
"captchaToken=${SmartCaptchaLog.redactToken(captchaToken)}",
|
||||||
|
)
|
||||||
fullLogin(username.trim(), password.trim()) {
|
fullLogin(username.trim(), password.trim()) {
|
||||||
val derived = deriveAuthSecret(username.trim(), password.trim())
|
val derived = deriveAuthSecret(username.trim(), password.trim())
|
||||||
ApiClient.authRegisterConfirm(
|
ApiClient.authRegisterConfirm(
|
||||||
@@ -159,17 +182,25 @@ internal suspend fun register(
|
|||||||
confirm_password = derived,
|
confirm_password = derived,
|
||||||
bio = bio.trim().takeIf { it.isNotEmpty() },
|
bio = bio.trim().takeIf { it.isNotEmpty() },
|
||||||
registration_proof = registrationProof,
|
registration_proof = registrationProof,
|
||||||
|
captcha_token = captchaToken,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "register confirm success username=${username.trim()}")
|
||||||
RegisterResult.Success
|
RegisterResult.Success
|
||||||
} catch (e: ClientRequestException) {
|
} catch (e: ClientRequestException) {
|
||||||
|
Logger.w(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"register confirm HTTP ${e.response.status.value} username=${username.trim()}",
|
||||||
|
e,
|
||||||
|
)
|
||||||
if (e.response.status.value == 400 && isUsernameTakenError(e)) {
|
if (e.response.status.value == 400 && isUsernameTakenError(e)) {
|
||||||
RegisterResult.UsernameTaken
|
RegisterResult.UsernameTaken
|
||||||
} else {
|
} else {
|
||||||
RegisterResult.Error(parseClientError(e, unexpectedError))
|
RegisterResult.Error(parseClientError(e, unexpectedError))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Logger.e(SmartCaptchaLog.TAG, "register confirm failed username=${username.trim()}", e)
|
||||||
RegisterResult.Error(unexpectedError, e)
|
RegisterResult.Error(unexpectedError, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +247,11 @@ fun AuthScreen(
|
|||||||
var bio by remember { mutableStateOf(AuthRegisterDraft.bio) }
|
var bio by remember { mutableStateOf(AuthRegisterDraft.bio) }
|
||||||
var yandexRequired by remember { mutableStateOf(AuthRegisterDraft.yandexRequired) }
|
var yandexRequired by remember { mutableStateOf(AuthRegisterDraft.yandexRequired) }
|
||||||
var yandexParams by remember { mutableStateOf(AuthRegisterDraft.yandexParams) }
|
var yandexParams by remember { mutableStateOf(AuthRegisterDraft.yandexParams) }
|
||||||
|
var captchaRequired by remember { mutableStateOf(AuthRegisterDraft.captchaRequired) }
|
||||||
|
var captchaParams by remember { mutableStateOf(AuthRegisterDraft.captchaParams) }
|
||||||
var registrationProof by remember { mutableStateOf(AuthRegisterDraft.registrationProof) }
|
var registrationProof by remember { mutableStateOf(AuthRegisterDraft.registrationProof) }
|
||||||
|
var captchaToken by remember { mutableStateOf(AuthRegisterDraft.captchaToken) }
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
|
||||||
fun persistDraft() {
|
fun persistDraft() {
|
||||||
AuthRegisterDraft.username = username
|
AuthRegisterDraft.username = username
|
||||||
@@ -226,7 +261,10 @@ fun AuthScreen(
|
|||||||
AuthRegisterDraft.bio = bio
|
AuthRegisterDraft.bio = bio
|
||||||
AuthRegisterDraft.yandexRequired = yandexRequired
|
AuthRegisterDraft.yandexRequired = yandexRequired
|
||||||
AuthRegisterDraft.yandexParams = yandexParams
|
AuthRegisterDraft.yandexParams = yandexParams
|
||||||
|
AuthRegisterDraft.captchaRequired = captchaRequired
|
||||||
|
AuthRegisterDraft.captchaParams = captchaParams
|
||||||
AuthRegisterDraft.registrationProof = registrationProof
|
AuthRegisterDraft.registrationProof = registrationProof
|
||||||
|
AuthRegisterDraft.captchaToken = captchaToken
|
||||||
AuthRegisterDraft.page = flowState.pagerState.currentPage
|
AuthRegisterDraft.page = flowState.pagerState.currentPage
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +295,10 @@ fun AuthScreen(
|
|||||||
bio = ""
|
bio = ""
|
||||||
yandexRequired = false
|
yandexRequired = false
|
||||||
yandexParams = null
|
yandexParams = null
|
||||||
|
captchaRequired = false
|
||||||
|
captchaParams = null
|
||||||
registrationProof = null
|
registrationProof = null
|
||||||
|
captchaToken = null
|
||||||
AuthRegisterDraft.clear()
|
AuthRegisterDraft.clear()
|
||||||
flowState.resetPredictiveState()
|
flowState.resetPredictiveState()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -269,7 +310,19 @@ fun AuthScreen(
|
|||||||
onDispose { persistDraft() }
|
onDispose { persistDraft() }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(username, password, confirmPassword, displayName, bio, yandexRequired, yandexParams, registrationProof) {
|
LaunchedEffect(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
confirmPassword,
|
||||||
|
displayName,
|
||||||
|
bio,
|
||||||
|
yandexRequired,
|
||||||
|
yandexParams,
|
||||||
|
captchaRequired,
|
||||||
|
captchaParams,
|
||||||
|
registrationProof,
|
||||||
|
captchaToken,
|
||||||
|
) {
|
||||||
persistDraft()
|
persistDraft()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +337,12 @@ fun AuthScreen(
|
|||||||
snapshotFlow { flowState.pagerState.currentPage }
|
snapshotFlow { flowState.pagerState.currentPage }
|
||||||
.collect { page ->
|
.collect { page ->
|
||||||
AuthRegisterDraft.page = page
|
AuthRegisterDraft.page = page
|
||||||
if (page == AuthFlowStep.YandexId.ordinal && !yandexRequired) {
|
// Don't skip the Yandex slot mid predictive-back — that fights pager morph
|
||||||
|
// (Profile ↔ ConfirmPassword) and glitches when the gesture is cancelled.
|
||||||
|
if (page == AuthFlowStep.YandexId.ordinal &&
|
||||||
|
!yandexRequired &&
|
||||||
|
flowState.predictiveFromPage == null
|
||||||
|
) {
|
||||||
val target = if (page > settledPage) {
|
val target = if (page > settledPage) {
|
||||||
AuthFlowStep.Profile.ordinal
|
AuthFlowStep.Profile.ordinal
|
||||||
} else {
|
} else {
|
||||||
@@ -301,7 +359,10 @@ fun AuthScreen(
|
|||||||
confirmPassword = ""
|
confirmPassword = ""
|
||||||
yandexRequired = false
|
yandexRequired = false
|
||||||
yandexParams = null
|
yandexParams = null
|
||||||
|
captchaRequired = false
|
||||||
|
captchaParams = null
|
||||||
registrationProof = null
|
registrationProof = null
|
||||||
|
captchaToken = null
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthFlowStep.Password.ordinal -> {
|
AuthFlowStep.Password.ordinal -> {
|
||||||
@@ -309,12 +370,16 @@ fun AuthScreen(
|
|||||||
confirmPassword = ""
|
confirmPassword = ""
|
||||||
yandexRequired = false
|
yandexRequired = false
|
||||||
yandexParams = null
|
yandexParams = null
|
||||||
|
captchaRequired = false
|
||||||
|
captchaParams = null
|
||||||
registrationProof = null
|
registrationProof = null
|
||||||
|
captchaToken = null
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthFlowStep.ConfirmPassword.ordinal -> {
|
AuthFlowStep.ConfirmPassword.ordinal -> {
|
||||||
// Keep confirm password when returning from Yandex ID / OAuth.
|
// Keep confirm password when returning from Yandex ID / OAuth / captcha.
|
||||||
registrationProof = null
|
registrationProof = null
|
||||||
|
captchaToken = null
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthFlowStep.YandexId.ordinal -> {
|
AuthFlowStep.YandexId.ordinal -> {
|
||||||
@@ -326,6 +391,47 @@ fun AuthScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After predictive back settles on the skipped Yandex slot, jump to ConfirmPassword.
|
||||||
|
LaunchedEffect(flowState.predictiveFromPage, yandexRequired) {
|
||||||
|
if (flowState.predictiveFromPage != null || yandexRequired) return@LaunchedEffect
|
||||||
|
if (flowState.pagerState.currentPage == AuthFlowStep.YandexId.ordinal) {
|
||||||
|
flowState.pagerState.scrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(navController) {
|
||||||
|
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||||
|
handle.getStateFlow<String?>(SmartCaptchaNav.RESULT_TOKEN, null).collect { token ->
|
||||||
|
if (token == null) return@collect
|
||||||
|
handle.remove<String>(SmartCaptchaNav.RESULT_TOKEN)
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"AuthScreen received token ${SmartCaptchaLog.redactToken(token)} → Profile",
|
||||||
|
)
|
||||||
|
captchaToken = token
|
||||||
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(navController) {
|
||||||
|
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||||
|
handle.getStateFlow<String?>(SmartCaptchaNav.RESULT_ERROR, null).collect { message ->
|
||||||
|
if (message == null) return@collect
|
||||||
|
handle.remove<String>(SmartCaptchaNav.RESULT_ERROR)
|
||||||
|
Logger.w(SmartCaptchaLog.TAG, "AuthScreen captcha error: $message")
|
||||||
|
snackbar(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openCaptchaRoute(clientKey: String) {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"navigate ${SmartCaptchaNav.ROUTE} clientKey=${SmartCaptchaLog.redactKey(clientKey)}",
|
||||||
|
)
|
||||||
|
SmartCaptchaNav.pending = SmartCaptchaNav.Session(clientKey = clientKey)
|
||||||
|
navController.navigate(SmartCaptchaNav.ROUTE)
|
||||||
|
}
|
||||||
|
|
||||||
val yandexStep = yandexParams
|
val yandexStep = yandexParams
|
||||||
ExpressiveStepFlowScaffold(
|
ExpressiveStepFlowScaffold(
|
||||||
flowState = flowState,
|
flowState = flowState,
|
||||||
@@ -343,10 +449,18 @@ fun AuthScreen(
|
|||||||
password = password,
|
password = password,
|
||||||
onPasswordChange = { password = it },
|
onPasswordChange = { password = it },
|
||||||
onLoginSuccess = wrappedAuthSuccess,
|
onLoginSuccess = wrappedAuthSuccess,
|
||||||
onNeedsRegister = { required, params ->
|
onNeedsRegister = { required, params, captchaReq, captcha ->
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"onNeedsRegister yandexRequired=$required captchaRequired=$captchaReq " +
|
||||||
|
"clientKey=${SmartCaptchaLog.redactKey(captcha?.client_key)}",
|
||||||
|
)
|
||||||
yandexRequired = required
|
yandexRequired = required
|
||||||
yandexParams = params
|
yandexParams = params
|
||||||
|
captchaRequired = captchaReq
|
||||||
|
captchaParams = captcha
|
||||||
registrationProof = null
|
registrationProof = null
|
||||||
|
captchaToken = null
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
||||||
},
|
},
|
||||||
onSnackbar = ::snackbar,
|
onSnackbar = ::snackbar,
|
||||||
@@ -356,10 +470,23 @@ fun AuthScreen(
|
|||||||
onConfirmPasswordChange = { confirmPassword = it },
|
onConfirmPasswordChange = { confirmPassword = it },
|
||||||
password = password,
|
password = password,
|
||||||
onContinue = {
|
onContinue = {
|
||||||
if (yandexRequired && yandexParams != null) {
|
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
|
when {
|
||||||
} else {
|
yandexRequired && yandexParams != null -> {
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
Logger.i(SmartCaptchaLog.TAG, "confirm → YandexId (captcha skipped)")
|
||||||
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
|
||||||
|
}
|
||||||
|
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
|
||||||
|
openCaptchaRoute(captchaKey)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"confirm → Profile captchaRequired=$captchaRequired " +
|
||||||
|
"hasToken=${!captchaToken.isNullOrBlank()}",
|
||||||
|
)
|
||||||
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSnackbar = ::snackbar,
|
onSnackbar = ::snackbar,
|
||||||
@@ -379,7 +506,16 @@ fun AuthScreen(
|
|||||||
onConfirmPasswordChange = { confirmPassword = it },
|
onConfirmPasswordChange = { confirmPassword = it },
|
||||||
password = password,
|
password = password,
|
||||||
onContinue = {
|
onContinue = {
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
||||||
|
when {
|
||||||
|
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
|
||||||
|
openCaptchaRoute(captchaKey)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "yandex-placeholder confirm → Profile")
|
||||||
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSnackbar = ::snackbar,
|
onSnackbar = ::snackbar,
|
||||||
)
|
)
|
||||||
@@ -392,6 +528,7 @@ fun AuthScreen(
|
|||||||
onBioChange = { bio = it },
|
onBioChange = { bio = it },
|
||||||
password = password,
|
password = password,
|
||||||
registrationProof = registrationProof,
|
registrationProof = registrationProof,
|
||||||
|
captchaToken = captchaToken,
|
||||||
onRegisterSuccess = wrappedAuthSuccess,
|
onRegisterSuccess = wrappedAuthSuccess,
|
||||||
onUsernameTaken = resetToUsername,
|
onUsernameTaken = resetToUsername,
|
||||||
onSnackbar = ::snackbar,
|
onSnackbar = ::snackbar,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import ru.fromchat.login
|
|||||||
import ru.fromchat.password
|
import ru.fromchat.password
|
||||||
import ru.fromchat.password_length_error
|
import ru.fromchat.password_length_error
|
||||||
import ru.fromchat.show_password
|
import ru.fromchat.show_password
|
||||||
|
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
|
||||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
import ru.fromchat.ui.components.ActionButton
|
import ru.fromchat.ui.components.ActionButton
|
||||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||||
@@ -54,7 +55,12 @@ internal fun passwordStepPage(
|
|||||||
password: String,
|
password: String,
|
||||||
onPasswordChange: (String) -> Unit,
|
onPasswordChange: (String) -> Unit,
|
||||||
onLoginSuccess: () -> Unit,
|
onLoginSuccess: () -> Unit,
|
||||||
onNeedsRegister: suspend (yandexRequired: Boolean, yandex: YandexOAuthParams?) -> Unit,
|
onNeedsRegister: suspend (
|
||||||
|
yandexRequired: Boolean,
|
||||||
|
yandex: YandexOAuthParams?,
|
||||||
|
captchaRequired: Boolean,
|
||||||
|
captcha: SmartCaptchaParams?,
|
||||||
|
) -> Unit,
|
||||||
onSnackbar: (String, Throwable?) -> Unit,
|
onSnackbar: (String, Throwable?) -> Unit,
|
||||||
): ExpressiveStepPage {
|
): ExpressiveStepPage {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -135,7 +141,12 @@ internal fun passwordStepPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is PasswordStepResult.NeedsRegister -> {
|
is PasswordStepResult.NeedsRegister -> {
|
||||||
onNeedsRegister(result.yandexRequired, result.yandex)
|
onNeedsRegister(
|
||||||
|
result.yandexRequired,
|
||||||
|
result.yandex,
|
||||||
|
result.captchaRequired,
|
||||||
|
result.captcha,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is PasswordStepResult.WrongPassword -> {
|
is PasswordStepResult.WrongPassword -> {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package ru.fromchat.ui.auth.captcha
|
||||||
|
|
||||||
|
/** Safe summaries for SmartCaptcha logs (never dump full tokens/secrets). */
|
||||||
|
internal object SmartCaptchaLog {
|
||||||
|
const val TAG = "SmartCaptcha"
|
||||||
|
|
||||||
|
fun redactKey(value: String?): String {
|
||||||
|
val v = value?.trim().orEmpty()
|
||||||
|
if (v.isEmpty()) return "(empty)"
|
||||||
|
if (v.length <= 8) return "len=${v.length}"
|
||||||
|
return "len=${v.length} prefix=${v.take(4)}…suffix=${v.takeLast(4)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun redactToken(value: String?): String {
|
||||||
|
val v = value?.trim().orEmpty()
|
||||||
|
if (v.isEmpty()) return "(empty)"
|
||||||
|
return "len=${v.length} prefix=${v.take(6)}…"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shortUrl(url: String?): String {
|
||||||
|
if (url.isNullOrBlank()) return "null"
|
||||||
|
// Drop query sitekey from logs; keep path/host.
|
||||||
|
val q = url.indexOf('?')
|
||||||
|
val base = if (q >= 0) url.substring(0, q) else url
|
||||||
|
return if (base.length <= 120) base else base.take(117) + "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package ru.fromchat.ui.auth.captcha
|
||||||
|
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
|
import kotlin.concurrent.Volatile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root [androidx.navigation.NavController] route for SmartCaptcha.
|
||||||
|
* Client key is staged in [pending] before navigate (not embedded in the route).
|
||||||
|
*/
|
||||||
|
internal object SmartCaptchaNav {
|
||||||
|
const val ROUTE = "smartCaptcha"
|
||||||
|
const val RESULT_TOKEN = "smartcaptcha_token"
|
||||||
|
const val RESULT_ERROR = "smartcaptcha_error"
|
||||||
|
|
||||||
|
data class Session(
|
||||||
|
val clientKey: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
val SessionSaver = listSaver<Session?, String>(
|
||||||
|
save = { session ->
|
||||||
|
if (session == null) emptyList()
|
||||||
|
else listOf(session.clientKey)
|
||||||
|
},
|
||||||
|
restore = { saved ->
|
||||||
|
if (saved.isEmpty()) null
|
||||||
|
else Session(clientKey = saved[0])
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var pending: Session? = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package ru.fromchat.ui.auth.captcha
|
||||||
|
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.auth_captcha_failed
|
||||||
|
import ru.fromchat.auth_captcha_title
|
||||||
|
import ru.fromchat.back
|
||||||
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
import ru.fromchat.ui.components.Text
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-route SmartCaptcha screen (shown after password confirm when Yandex OAuth is off).
|
||||||
|
* Returns a token / error via [SmartCaptchaNav] saved-state results.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun SmartCaptchaScreen() {
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
var session by rememberSaveable(stateSaver = SmartCaptchaNav.SessionSaver) {
|
||||||
|
mutableStateOf(SmartCaptchaNav.pending)
|
||||||
|
}
|
||||||
|
var pageReady by remember { mutableStateOf(false) }
|
||||||
|
val failedMessage = stringResource(Res.string.auth_captcha_failed)
|
||||||
|
val barColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
val languageTag = Locale.current.toLanguageTag()
|
||||||
|
val screenId = remember { (100000..999999).random().toString(16) }
|
||||||
|
|
||||||
|
DisposableEffect(screenId) {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"route enter id=$screenId sessionNull=${session == null} " +
|
||||||
|
"pendingNull=${SmartCaptchaNav.pending == null} " +
|
||||||
|
"clientKey=${SmartCaptchaLog.redactKey(session?.clientKey)} languageTag=$languageTag",
|
||||||
|
)
|
||||||
|
onDispose {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "route dispose id=$screenId pageReady=$pageReady")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(session) {
|
||||||
|
if (session == null) {
|
||||||
|
Logger.w(SmartCaptchaLog.TAG, "session null → popBackStack id=$screenId")
|
||||||
|
navController.popBackStack()
|
||||||
|
} else {
|
||||||
|
SmartCaptchaNav.pending = session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val active = session ?: return
|
||||||
|
|
||||||
|
fun finishWithToken(token: String) {
|
||||||
|
Logger.i(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"finishWithToken id=$screenId ${SmartCaptchaLog.redactToken(token)}",
|
||||||
|
)
|
||||||
|
SmartCaptchaNav.pending = null
|
||||||
|
navController.previousBackStackEntry
|
||||||
|
?.savedStateHandle
|
||||||
|
?.set(SmartCaptchaNav.RESULT_TOKEN, token)
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishWithError(message: String) {
|
||||||
|
Logger.w(SmartCaptchaLog.TAG, "finishWithError id=$screenId message=$message")
|
||||||
|
SmartCaptchaNav.pending = null
|
||||||
|
navController.previousBackStackEntry
|
||||||
|
?.savedStateHandle
|
||||||
|
?.set(SmartCaptchaNav.RESULT_ERROR, message)
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "cancel id=$screenId")
|
||||||
|
SmartCaptchaNav.pending = null
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
// Top bar draws into the status bar; bottom nav-bar inset keeps the WebView above it
|
||||||
|
// while [containerColor] still paints the gesture/nav area.
|
||||||
|
contentWindowInsets = WindowInsets.navigationBars,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(Res.string.auth_captcha_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { cancel() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(Res.string.back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = barColor,
|
||||||
|
scrolledContainerColor = barColor,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
containerColor = barColor,
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.TopCenter,
|
||||||
|
) {
|
||||||
|
SmartCaptchaWebView(
|
||||||
|
sitekey = active.clientKey,
|
||||||
|
languageTag = languageTag,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onToken = { finishWithToken(it) },
|
||||||
|
onReady = {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "route pageReady id=$screenId")
|
||||||
|
pageReady = true
|
||||||
|
},
|
||||||
|
onChallengeVisible = {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "route challengeVisible id=$screenId")
|
||||||
|
},
|
||||||
|
onChallengeHidden = {
|
||||||
|
Logger.i(SmartCaptchaLog.TAG, "route challengeHidden id=$screenId")
|
||||||
|
},
|
||||||
|
onError = { message ->
|
||||||
|
finishWithError(message.ifBlank { failedMessage })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
visible = !pageReady,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(barColor),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularWavyProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package ru.fromchat.ui.auth.captcha
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform WebView that loads Yandex SmartCaptcha and reports the verification token.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
expect fun SmartCaptchaWebView(
|
||||||
|
sitekey: String,
|
||||||
|
languageTag: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onToken: (String) -> Unit,
|
||||||
|
onReady: () -> Unit = {},
|
||||||
|
onChallengeVisible: () -> Unit = {},
|
||||||
|
onChallengeHidden: () -> Unit = {},
|
||||||
|
onError: (String) -> Unit = {},
|
||||||
|
)
|
||||||
@@ -56,6 +56,7 @@ internal fun profileStepPage(
|
|||||||
onBioChange: (String) -> Unit,
|
onBioChange: (String) -> Unit,
|
||||||
password: String,
|
password: String,
|
||||||
registrationProof: String?,
|
registrationProof: String?,
|
||||||
|
captchaToken: String?,
|
||||||
onRegisterSuccess: () -> Unit,
|
onRegisterSuccess: () -> Unit,
|
||||||
onUsernameTaken: () -> Unit,
|
onUsernameTaken: () -> Unit,
|
||||||
onSnackbar: (String, Throwable?) -> Unit,
|
onSnackbar: (String, Throwable?) -> Unit,
|
||||||
@@ -149,6 +150,7 @@ internal fun profileStepPage(
|
|||||||
password = password,
|
password = password,
|
||||||
bio = bio.trim(),
|
bio = bio.trim(),
|
||||||
registrationProof = registrationProof,
|
registrationProof = registrationProof,
|
||||||
|
captchaToken = captchaToken,
|
||||||
unexpectedError = unexpected,
|
unexpectedError = unexpected,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -400,13 +400,13 @@ fun ExpressiveStepFlowScaffold(
|
|||||||
flowState.resetPredictiveState()
|
flowState.resetPredictiveState()
|
||||||
return@PredictiveBackHandler
|
return@PredictiveBackHandler
|
||||||
}
|
}
|
||||||
|
// Cancel must always reverse — never commit just because progress crossed the threshold.
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val commit = lastProgress >= predictiveThreshold
|
|
||||||
finishPredictiveMorph(
|
finishPredictiveMorph(
|
||||||
flowState = flowState,
|
flowState = flowState,
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
startProgress = lastProgress,
|
startProgress = lastProgress,
|
||||||
targetProgress = if (commit) 1f else 0f,
|
targetProgress = 0f,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package ru.fromchat.ui.auth.captcha
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun SmartCaptchaWebView(
|
||||||
|
sitekey: String,
|
||||||
|
languageTag: String,
|
||||||
|
modifier: Modifier,
|
||||||
|
onToken: (String) -> Unit,
|
||||||
|
onReady: () -> Unit,
|
||||||
|
onChallengeVisible: () -> Unit,
|
||||||
|
onChallengeHidden: () -> Unit,
|
||||||
|
onError: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
LaunchedEffect(sitekey) {
|
||||||
|
Logger.w(
|
||||||
|
SmartCaptchaLog.TAG,
|
||||||
|
"iOS stub: captcha unavailable sitekey=${SmartCaptchaLog.redactKey(sitekey)} " +
|
||||||
|
"languageTag=$languageTag",
|
||||||
|
)
|
||||||
|
onError("Captcha is not available on this platform yet.")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user