6 Commits

21 changed files with 770 additions and 41 deletions
-11
View File
@@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "gradle" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+2
View File
@@ -32,3 +32,5 @@ google-services.json
premortem-transcript-*.md
premortem-report-*.html
target/
@@ -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
},
)
}
@@ -31,6 +31,7 @@
<string name="display_name_error">От 1 до 64 символов</string>
<string name="fill_all_fields">Заполните все поля</string>
<string name="username_length_error">Имя пользователя — от 3 до 20 символов</string>
<string name="username_chars_error">Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания</string>
<string name="password_length_error">Пароль — от 5 до 50 символов</string>
<string name="passwords_dont_match">Пароли не совпадают</string>
<string name="auth_welcome_title">Добро пожаловать в FromChat</string>
@@ -54,6 +55,9 @@
<string name="auth_step_yandex_title">Войдите через Яндекс ID</string>
<string name="auth_step_yandex_body">Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. От Яндекса мы получаем только email — и мы его не сохраняем: вход нужен лишь для защиты.</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_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
@@ -38,6 +38,7 @@
<!-- Validation Errors -->
<string name="fill_all_fields">Please fill in every field</string>
<string name="username_length_error">Username must be 3 to 20 characters</string>
<string name="username_chars_error">Username can only contain English letters, numbers, hyphens and underscores</string>
<string name="password_length_error">Password must be 5 to 50 characters</string>
<string name="passwords_dont_match">The two passwords dont match</string>
<string name="auth_welcome_title">Welcome to FromChat</string>
@@ -61,6 +62,9 @@
<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 dont 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_captcha_title">Quick check</string>
<string name="auth_captcha_body">Confirm youre 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_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>
@@ -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<JsonObject>()
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))
}
}
@@ -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,
)
@@ -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,
@@ -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
}
}
@@ -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<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
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,
@@ -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 -> {
@@ -42,8 +42,13 @@ import ru.fromchat.ui.components.expressiveStepFieldColors
import ru.fromchat.ui.components.trackImeScrollTarget
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
import ru.fromchat.username
import ru.fromchat.username_chars_error
import ru.fromchat.username_length_error
/** Matches backend `is_valid_username`: English letters, digits, hyphen, underscore. */
private fun isAllowedUsernameChar(ch: Char) =
ch in 'a'..'z' || ch in 'A'..'Z' || ch in '0'..'9' || ch == '_' || ch == '-'
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun usernameStepPage(
@@ -59,9 +64,11 @@ internal fun usernameStepPage(
val fillAll = stringResource(Res.string.fill_all_fields)
val usernameLenError = stringResource(Res.string.username_length_error)
val usernameCharsError = stringResource(Res.string.username_chars_error)
val serverFail = stringResource(Res.string.auth_server_connect_failed)
val unexpected = stringResource(Res.string.error_unexpected)
val nextLabel = stringResource(Res.string.settings_next)
val hasProhibitedChars = username.trim().any { !isAllowedUsernameChar(it) }
return ExpressiveStepPage(
hero = ExpressiveHeroSpec(
@@ -90,6 +97,12 @@ internal fun usernameStepPage(
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
.padding(horizontal = SettingsStepHorizontalPadding),
singleLine = true,
isError = hasProhibitedChars,
supportingText = if (hasProhibitedChars) {
{ Text(usernameCharsError) }
} else {
null
},
colors = expressiveStepFieldColors(),
shape = SettingsPasswordOutlineFieldShape,
)
@@ -98,7 +111,7 @@ internal fun usernameStepPage(
button = {
ActionButton(
onClick = {
if (busy) return@ActionButton
if (busy || hasProhibitedChars) return@ActionButton
val trimmed = username.trim()
if (trimmed.isBlank()) {
onSnackbar(fillAll, null)
@@ -136,7 +149,7 @@ internal fun usernameStepPage(
}
}
},
enabled = !busy,
enabled = !busy && !hasProhibitedChars,
loading = busy,
modifier = Modifier.fillMaxWidth(),
) {
@@ -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,
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,
)
) {
@@ -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,
)
}
},
@@ -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.")
}
}
+2 -2
View File
@@ -9,8 +9,8 @@ plugins {
}
/** Single source of truth for app version (APK + generated [AppBuildInfo]). */
extra["versionName"] = "1.1.3"
extra["versionCode"] = 113
extra["versionName"] = "1.1.4"
extra["versionCode"] = 114
buildscript {
repositories {
+1 -1
View File
@@ -1,5 +1,5 @@
[versions]
agp = "9.3.0"
agp = "9.3.1"
androidx-activityCompose = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-core-ktx = "1.19.0"