diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.android.kt new file mode 100644 index 0000000..1062f54 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.android.kt @@ -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(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 + }, + ) +} diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 5521743..5107de8 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -55,6 +55,9 @@ Войдите через Яндекс ID Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. От Яндекса мы получаем только email — и мы его не сохраняем: вход нужен лишь для защиты. Продолжить через Яндекс ID + Быстрая проверка + Подтвердите, что вы человек, чтобы продолжить создание аккаунта. + Не удалось пройти проверку. Попробуйте ещё раз. Яндекс ID Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку. Вход через Яндекс ID отменён или не удался. diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index fca2c67..744ee80 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -62,6 +62,9 @@ Sign in with Yandex ID 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. Continue with Yandex ID + Quick check + Confirm you’re human to continue creating your account. + Captcha verification failed. Please try again. Yandex ID This server returned an unexpected Yandex app id. Update the app or contact support. Yandex sign-in was cancelled or failed. diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index 1ee8491..82d15da 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -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.LoginResponse 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.YandexExchangeResponse import ru.fromchat.api.schema.user.auth.YandexOAuthParams @@ -495,6 +496,8 @@ object ApiClient { data class NeedsRegister( val yandexRequired: Boolean, val yandex: YandexOAuthParams?, + val captchaRequired: Boolean, + val captcha: SmartCaptchaParams?, ) : AuthPasswordStepOutcome } @@ -512,10 +515,26 @@ object ApiClient { .body() val status = raw["status"]?.jsonPrimitive?.contentOrNull return when (status) { - "needs_register" -> AuthPasswordStepOutcome.NeedsRegister( - yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true, - yandex = raw["yandex"]?.let { json.decodeFromJsonElement(YandexOAuthParams.serializer(), it) }, - ) + "needs_register" -> { + val yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true + 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)) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/AuthSteps.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/AuthSteps.kt index 32eec47..03b9cca 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/AuthSteps.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/AuthSteps.kt @@ -27,11 +27,18 @@ data class YandexOAuthParams( val scope: String, ) +@Serializable +data class SmartCaptchaParams( + val client_key: String, +) + @Serializable data class AuthNeedsRegisterResponse( val status: String, val yandex_required: Boolean = false, val yandex: YandexOAuthParams? = null, + val captcha_required: Boolean = false, + val captcha: SmartCaptchaParams? = null, ) @Serializable @@ -70,4 +77,5 @@ data class RegisterConfirmRequest( val confirm_password: String, val bio: String? = null, val registration_proof: String? = null, + val captcha_token: String? = null, ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 0079b3f..4d76a70 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -79,6 +79,8 @@ import ru.fromchat.legal.DocumentScreen import ru.fromchat.legal.DocumentType import ru.fromchat.notifications.NotificationLaunchCoordinator 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.YandexOAuthScreen import ru.fromchat.ui.calls.CallOverlay @@ -466,6 +468,10 @@ fun App( YandexOAuthScreen() } + composable(SmartCaptchaNav.ROUTE) { + SmartCaptchaScreen() + } + composable("chat") { MainScreen( sharedTransitionScope = this@SharedTransitionLayout, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthRegisterDraft.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthRegisterDraft.kt index 362cb35..87f5c4b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthRegisterDraft.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthRegisterDraft.kt @@ -1,9 +1,10 @@ package ru.fromchat.ui.auth +import ru.fromchat.api.schema.user.auth.SmartCaptchaParams 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. */ internal object AuthRegisterDraft { @@ -14,7 +15,10 @@ internal object AuthRegisterDraft { var bio: String = "" var yandexRequired: Boolean = false var yandexParams: YandexOAuthParams? = null + var captchaRequired: Boolean = false + var captchaParams: SmartCaptchaParams? = null var registrationProof: String? = null + var captchaToken: String? = null var page: Int = 0 fun clear() { @@ -25,7 +29,10 @@ internal object AuthRegisterDraft { bio = "" yandexRequired = false yandexParams = null + captchaRequired = false + captchaParams = null registrationProof = null + captchaToken = null page = 0 } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt index e44b9cf..7024620 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt @@ -20,20 +20,24 @@ import io.ktor.client.plugins.ClientRequestException import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Logger import ru.fromchat.Res import ru.fromchat.api.ApiClient 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.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.user.auth.LoginResponse 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.change_server import ru.fromchat.config.Settings 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.profileStepPage import ru.fromchat.ui.auth.yandex.yandexIdStepPage @@ -58,6 +62,8 @@ internal sealed interface PasswordStepResult { data class NeedsRegister( val yandexRequired: Boolean, val yandex: YandexOAuthParams?, + val captchaRequired: Boolean, + val captcha: SmartCaptchaParams?, ) : PasswordStepResult data class WrongPassword(val message: String) : PasswordStepResult data class RateLimited(val message: String) : PasswordStepResult @@ -120,10 +126,20 @@ internal suspend fun authPasswordStep( PasswordStepResult.LoginSuccess } - is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> PasswordStepResult.NeedsRegister( - yandexRequired = outcome.yandexRequired, - yandex = outcome.yandex, - ) + is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> { + Logger.i( + 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) { when (e.response.status.value) { @@ -147,8 +163,15 @@ internal suspend fun register( password: String, bio: String, registrationProof: String?, + captchaToken: String?, unexpectedError: String, ) = try { + Logger.i( + SmartCaptchaLog.TAG, + "register confirm start username=${username.trim()} " + + "hasRegistrationProof=${!registrationProof.isNullOrBlank()} " + + "captchaToken=${SmartCaptchaLog.redactToken(captchaToken)}", + ) fullLogin(username.trim(), password.trim()) { val derived = deriveAuthSecret(username.trim(), password.trim()) ApiClient.authRegisterConfirm( @@ -159,17 +182,25 @@ internal suspend fun register( confirm_password = derived, bio = bio.trim().takeIf { it.isNotEmpty() }, registration_proof = registrationProof, + captcha_token = captchaToken, ), ) } + Logger.i(SmartCaptchaLog.TAG, "register confirm success username=${username.trim()}") RegisterResult.Success } 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)) { RegisterResult.UsernameTaken } else { RegisterResult.Error(parseClientError(e, unexpectedError)) } } catch (e: Exception) { + Logger.e(SmartCaptchaLog.TAG, "register confirm failed username=${username.trim()}", e) RegisterResult.Error(unexpectedError, e) } @@ -216,7 +247,11 @@ fun AuthScreen( var bio by remember { mutableStateOf(AuthRegisterDraft.bio) } var yandexRequired by remember { mutableStateOf(AuthRegisterDraft.yandexRequired) } 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 captchaToken by remember { mutableStateOf(AuthRegisterDraft.captchaToken) } + val navController = LocalNavController.current fun persistDraft() { AuthRegisterDraft.username = username @@ -226,7 +261,10 @@ fun AuthScreen( AuthRegisterDraft.bio = bio AuthRegisterDraft.yandexRequired = yandexRequired AuthRegisterDraft.yandexParams = yandexParams + AuthRegisterDraft.captchaRequired = captchaRequired + AuthRegisterDraft.captchaParams = captchaParams AuthRegisterDraft.registrationProof = registrationProof + AuthRegisterDraft.captchaToken = captchaToken AuthRegisterDraft.page = flowState.pagerState.currentPage } @@ -257,7 +295,10 @@ fun AuthScreen( bio = "" yandexRequired = false yandexParams = null + captchaRequired = false + captchaParams = null registrationProof = null + captchaToken = null AuthRegisterDraft.clear() flowState.resetPredictiveState() scope.launch { @@ -269,7 +310,19 @@ fun AuthScreen( onDispose { persistDraft() } } - LaunchedEffect(username, password, confirmPassword, displayName, bio, yandexRequired, yandexParams, registrationProof) { + LaunchedEffect( + username, + password, + confirmPassword, + displayName, + bio, + yandexRequired, + yandexParams, + captchaRequired, + captchaParams, + registrationProof, + captchaToken, + ) { persistDraft() } @@ -284,7 +337,12 @@ fun AuthScreen( snapshotFlow { flowState.pagerState.currentPage } .collect { 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) { AuthFlowStep.Profile.ordinal } else { @@ -301,7 +359,10 @@ fun AuthScreen( confirmPassword = "" yandexRequired = false yandexParams = null + captchaRequired = false + captchaParams = null registrationProof = null + captchaToken = null } AuthFlowStep.Password.ordinal -> { @@ -309,12 +370,16 @@ fun AuthScreen( confirmPassword = "" yandexRequired = false yandexParams = null + captchaRequired = false + captchaParams = null registrationProof = null + captchaToken = null } AuthFlowStep.ConfirmPassword.ordinal -> { - // Keep confirm password when returning from Yandex ID / OAuth. + // Keep confirm password when returning from Yandex ID / OAuth / captcha. registrationProof = null + captchaToken = null } 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(SmartCaptchaNav.RESULT_TOKEN, null).collect { token -> + if (token == null) return@collect + handle.remove(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(SmartCaptchaNav.RESULT_ERROR, null).collect { message -> + if (message == null) return@collect + handle.remove(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 ExpressiveStepFlowScaffold( flowState = flowState, @@ -343,10 +449,18 @@ fun AuthScreen( password = password, onPasswordChange = { password = it }, 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 yandexParams = params + captchaRequired = captchaReq + captchaParams = captcha registrationProof = null + captchaToken = null flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal) }, onSnackbar = ::snackbar, @@ -356,10 +470,23 @@ fun AuthScreen( onConfirmPasswordChange = { confirmPassword = it }, password = password, onContinue = { - if (yandexRequired && yandexParams != null) { - flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal) - } else { - flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal) + val captchaKey = captchaParams?.client_key?.trim().orEmpty() + when { + yandexRequired && yandexParams != null -> { + 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, @@ -379,7 +506,16 @@ fun AuthScreen( onConfirmPasswordChange = { confirmPassword = it }, password = password, 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, ) @@ -392,6 +528,7 @@ fun AuthScreen( onBioChange = { bio = it }, password = password, registrationProof = registrationProof, + captchaToken = captchaToken, onRegisterSuccess = wrappedAuthSuccess, onUsernameTaken = resetToUsername, onSnackbar = ::snackbar, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt index 80998c6..e2e1c6a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt @@ -35,6 +35,7 @@ import ru.fromchat.login import ru.fromchat.password import ru.fromchat.password_length_error import ru.fromchat.show_password +import ru.fromchat.api.schema.user.auth.SmartCaptchaParams import ru.fromchat.api.schema.user.auth.YandexOAuthParams import ru.fromchat.ui.components.ActionButton import ru.fromchat.ui.components.ExpressiveHeroSpec @@ -54,7 +55,12 @@ internal fun passwordStepPage( password: String, onPasswordChange: (String) -> 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, ): ExpressiveStepPage { val scope = rememberCoroutineScope() @@ -135,7 +141,12 @@ internal fun passwordStepPage( } is PasswordStepResult.NeedsRegister -> { - onNeedsRegister(result.yandexRequired, result.yandex) + onNeedsRegister( + result.yandexRequired, + result.yandex, + result.captchaRequired, + result.captcha, + ) } is PasswordStepResult.WrongPassword -> { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaLog.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaLog.kt new file mode 100644 index 0000000..3c14d41 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaLog.kt @@ -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) + "..." + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaNav.kt new file mode 100644 index 0000000..2acf5c1 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaNav.kt @@ -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( + 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 +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaScreen.kt new file mode 100644 index 0000000..40ac74b --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaScreen.kt @@ -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() + } + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.kt new file mode 100644 index 0000000..50753a0 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.kt @@ -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 = {}, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt index d2648b5..1286db9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt @@ -56,6 +56,7 @@ internal fun profileStepPage( onBioChange: (String) -> Unit, password: String, registrationProof: String?, + captchaToken: String?, onRegisterSuccess: () -> Unit, onUsernameTaken: () -> Unit, onSnackbar: (String, Throwable?) -> Unit, @@ -149,6 +150,7 @@ internal fun profileStepPage( password = password, bio = bio.trim(), registrationProof = registrationProof, + captchaToken = captchaToken, unexpectedError = unexpected, ) ) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt index 0f07d06..dc84a76 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt @@ -400,13 +400,13 @@ fun ExpressiveStepFlowScaffold( flowState.resetPredictiveState() return@PredictiveBackHandler } + // Cancel must always reverse — never commit just because progress crossed the threshold. scope.launch { - val commit = lastProgress >= predictiveThreshold finishPredictiveMorph( flowState = flowState, pagerState = pagerState, startProgress = lastProgress, - targetProgress = if (commit) 1f else 0f, + targetProgress = 0f, ) } }, diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.ios.kt new file mode 100644 index 0000000..ba42a3f --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/captcha/SmartCaptchaWebView.ios.kt @@ -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.") + } +}