diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml
index 3ecb4d5..5208015 100644
--- a/app/android/src/main/AndroidManifest.xml
+++ b/app/android/src/main/AndroidManifest.xml
@@ -42,6 +42,15 @@
android:host="u"
android:pathPrefix="/" />
+
+
+
+
+
+
0 means the page start is off-screen. */
+private const val PAGE_SCROLL_Y_JS = """
+(function() {
+ var y = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
+ var nodes = document.querySelectorAll('html,body,main,#root,#app,[data-scroll],.Scroll,.scroll,.passp-page');
+ for (var i = 0; i < nodes.length; i++) {
+ try { y = Math.max(y, nodes[i].scrollTop || 0); } catch (e) {}
+ }
+ return y;
+})();
+"""
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@SuppressLint("SetJavaScriptEnabled")
+@Composable
+actual fun YandexOAuthWebView(
+ authorizeUrl: String,
+ languageTag: String,
+ darkTheme: Boolean,
+ fallbackColor: Color,
+ clearCookies: Boolean,
+ onPageBackgroundColor: (Color) -> Unit,
+ onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
+ onCode: (String) -> Unit,
+ onError: (String) -> Unit,
+ onCancel: () -> Unit,
+) {
+ var webView by remember { mutableStateOf(null) }
+ var canGoBack by remember { mutableStateOf(false) }
+ var pageLoading by remember { mutableStateOf(true) }
+ var showLoadingOverlay by remember { mutableStateOf(true) }
+ var chromeColor by remember { mutableStateOf(fallbackColor) }
+ // Survives Activity recreate so the OAuth page is not reloaded from authorizeUrl.
+ val savedWebViewState = rememberSaveable { Bundle() }
+ val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
+ val scheme = if (darkTheme) "dark" else "light"
+ val onHistoryBackAvailabilityChangedState = rememberUpdatedState(onHistoryBackAvailabilityChanged)
+ val onPageBackgroundColorState = rememberUpdatedState(onPageBackgroundColor)
+ val onCodeState = rememberUpdatedState(onCode)
+ val onErrorState = rememberUpdatedState(onError)
+ val onCancelState = rememberUpdatedState(onCancel)
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val instanceId = remember { Integer.toHexString(System.identityHashCode(Any())) }
+
+ DisposableEffect(instanceId) {
+ Logger.i(
+ LOG_TAG,
+ "compose enter id=$instanceId darkTheme=$darkTheme lang=$lang " +
+ "savedBundleEmpty=${savedWebViewState.isEmpty} " +
+ "savedBundleSize=${savedWebViewState.size()} " +
+ "lifecycle=${lifecycleOwner.lifecycle.currentState} " +
+ "authorizeUrl=${shortUrl(authorizeUrl)}",
+ )
+ onDispose {
+ Logger.i(LOG_TAG, "compose dispose id=$instanceId")
+ }
+ }
+
+ fun applyChromeColor(color: Color) {
+ if (chromeColor == color) return
+ chromeColor = color
+ onPageBackgroundColorState.value(color)
+ webView?.setBackgroundColor(color.toArgb())
+ }
+
+ fun updateCanGoBack(value: Boolean) {
+ if (canGoBack != value) {
+ Logger.d(LOG_TAG, "canGoBack $canGoBack → $value id=$instanceId")
+ canGoBack = value
+ onHistoryBackAvailabilityChangedState.value(value)
+ }
+ }
+
+ ApplyYandexWebViewSystemBars(
+ chromeColor = chromeColor,
+ darkTheme = darkTheme,
+ restoreSurfaceColor = fallbackColor,
+ )
+
+ LaunchedEffect(pageLoading) {
+ Logger.d(LOG_TAG, "pageLoading=$pageLoading → overlay scheduling id=$instanceId")
+ if (pageLoading) {
+ showLoadingOverlay = true
+ } else {
+ delay(LOADING_HIDE_DELAY_MS)
+ showLoadingOverlay = false
+ }
+ }
+
+ LaunchedEffect(webView, lifecycleOwner) {
+ val wv = webView ?: return@LaunchedEffect
+ // PixelCopy crashes if the window surface is gone (pause/stop). Only sample while resumed.
+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ Logger.d(LOG_TAG, "color sample loop start id=$instanceId")
+ while (isActive) {
+ // Only sample when the document top is visible — not the current scrolled viewport.
+ if (pageScrollY(wv) <= 1.0) {
+ sampleTopRowColor(wv)?.let { applyChromeColor(it) }
+ }
+ delay(TOP_ROW_SAMPLE_INTERVAL_MS)
+ }
+ }
+ Logger.d(LOG_TAG, "color sample loop paused (not resumed) id=$instanceId")
+ }
+
+ DisposableEffect(webView, lifecycleOwner) {
+ val wv = webView
+ if (wv == null) {
+ return@DisposableEffect onDispose { }
+ }
+ val observer = LifecycleEventObserver { _, event ->
+ Logger.i(
+ LOG_TAG,
+ "lifecycle $event id=$instanceId wv=${Integer.toHexString(System.identityHashCode(wv))} " +
+ "url=${shortUrl(wv.url)} progress=${wv.progress} canGoBack=${wv.canGoBack()}",
+ )
+ 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 {
+ Logger.d(LOG_TAG, "lifecycle observer dispose id=$instanceId")
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ wv.onPause()
+ }
+ }
+
+ // Always consume predictive back here. If NavHost owns the gesture it scales this
+ // screen and WebView jumps scroll-to-top + leaves a gap under the content.
+ PredictiveBackHandler(
+ enabled = true,
+ onProgress = { },
+ onCommit = {
+ val wv = webView
+ if (wv != null && wv.canGoBack()) {
+ wv.goBack()
+ } else {
+ onCancelState.value()
+ }
+ },
+ onCancel = { },
+ )
+
+ val client = remember(authorizeUrl, lang, darkTheme) {
+ Logger.i(
+ LOG_TAG,
+ "WebViewClient create id=$instanceId darkTheme=$darkTheme lang=$lang " +
+ "authorizeUrl=${shortUrl(authorizeUrl)}",
+ )
+ object : WebViewClient() {
+ private fun handleSpecialUrl(view: WebView?, url: String?): Boolean {
+ if (url.isNullOrBlank()) return false
+ if (url.startsWith("fromchat://", ignoreCase = true)) {
+ Logger.i(LOG_TAG, "intercept redirect url=${shortUrl(url)} id=$instanceId")
+ val code = extractOAuthCode(url)
+ if (code != null) {
+ onCodeState.value(code)
+ } else {
+ onErrorState.value("")
+ }
+ return true
+ }
+ if (!isYandexAuthNavigation(url)) {
+ Logger.d(LOG_TAG, "external nav url=${shortUrl(url)} id=$instanceId")
+ view?.context?.let { ctx ->
+ runCatching {
+ ctx.startActivity(
+ Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ },
+ )
+ }
+ }
+ return true
+ }
+ return false
+ }
+
+ private fun applyPageChrome(view: WebView?) {
+ view ?: return
+ view.evaluateJavascript("$FORCE_COLOR_SCHEME_JS('$scheme');", null)
+ view.evaluateJavascript(DISABLE_USER_SELECT_JS, null)
+ }
+
+ override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
+ val url = request?.url?.toString() ?: return false
+ return handleSpecialUrl(view, url)
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
+ return handleSpecialUrl(view, url)
+ }
+
+ override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+ Logger.i(
+ LOG_TAG,
+ "onPageStarted id=$instanceId url=${shortUrl(url)} " +
+ "wv=${view?.let { Integer.toHexString(System.identityHashCode(it)) }} " +
+ "canGoBack=${view?.canGoBack()} stack=${Throwable().stackTraceToString().lineSequence().take(8).joinToString(" ← ")}",
+ )
+ if (url != null && url.startsWith(YANDEX_OAUTH_REDIRECT_URI, ignoreCase = true)) {
+ handleSpecialUrl(view, url)
+ }
+ pageLoading = true
+ applyPageChrome(view)
+ updateCanGoBack(view?.canGoBack() == true)
+ }
+
+ override fun onPageFinished(view: WebView?, url: String?) {
+ Logger.i(
+ LOG_TAG,
+ "onPageFinished id=$instanceId url=${shortUrl(url)} " +
+ "progress=${view?.progress} canGoBack=${view?.canGoBack()} " +
+ "historySize=${view?.copyBackForwardList()?.size}",
+ )
+ pageLoading = false
+ applyPageChrome(view)
+ updateCanGoBack(view?.canGoBack() == true)
+ }
+
+ override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
+ Logger.d(
+ LOG_TAG,
+ "doUpdateVisitedHistory id=$instanceId isReload=$isReload url=${shortUrl(url)} " +
+ "canGoBack=${view?.canGoBack()}",
+ )
+ updateCanGoBack(view?.canGoBack() == true)
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(chromeColor),
+ ) {
+ AndroidView(
+ factory = { context ->
+ val activity = context.findActivity() ?: context
+ Logger.i(
+ LOG_TAG,
+ "AndroidView.factory START id=$instanceId " +
+ "savedEmpty=${savedWebViewState.isEmpty} savedSize=${savedWebViewState.size()} " +
+ "darkTheme=$darkTheme clearCookies=$clearCookies " +
+ "stack=${Throwable().stackTraceToString().lineSequence().drop(1).take(10).joinToString(" ← ")}",
+ )
+ if (clearCookies) {
+ clearYandexWebViewCookies()
+ }
+ seedYandexThemeCookies(darkTheme)
+ WebView(activity).apply {
+ setBackgroundColor(fallbackColor.toArgb())
+ settings.javaScriptEnabled = true
+ settings.domStorageEnabled = true
+ settings.javaScriptCanOpenWindowsAutomatically = true
+ settings.setSupportMultipleWindows(true)
+ applyYandexDarkSettingsIfNeeded(this, darkTheme)
+ webViewClient = client
+ webChromeClient = object : WebChromeClient() {
+ override fun onCreateWindow(
+ view: WebView?,
+ isDialog: Boolean,
+ isUserGesture: Boolean,
+ resultMsg: android.os.Message?,
+ ): Boolean {
+ val transport = resultMsg?.obj as? WebView.WebViewTransport ?: return false
+ val temp = WebView(activity).apply {
+ applyYandexDarkSettingsIfNeeded(this, darkTheme)
+ webViewClient = object : WebViewClient() {
+ override fun shouldOverrideUrlLoading(
+ v: WebView?,
+ request: WebResourceRequest?,
+ ): Boolean {
+ val url = request?.url?.toString() ?: return false
+ if (!isYandexAuthNavigation(url)) {
+ runCatching {
+ context.startActivity(
+ Intent(Intent.ACTION_VIEW, Uri.parse(url)),
+ )
+ }
+ } else {
+ view?.loadUrl(url)
+ }
+ return true
+ }
+ }
+ }
+ transport.webView = temp
+ resultMsg.sendToTarget()
+ return true
+ }
+ }
+ val hadSavedState = savedWebViewState.isEmpty.not()
+ val restoreList = if (hadSavedState) restoreState(savedWebViewState) else null
+ val historySize = copyBackForwardList().size
+ val restored = hadSavedState && restoreList != null && historySize > 0
+ Logger.i(
+ LOG_TAG,
+ "AndroidView.factory restore id=$instanceId hadSaved=$hadSavedState " +
+ "restoreListNull=${restoreList == null} historySize=$historySize " +
+ "restored=$restored wv=${Integer.toHexString(System.identityHashCode(this))}",
+ )
+ if (restored) {
+ pageLoading = false
+ showLoadingOverlay = false
+ updateCanGoBack(canGoBack())
+ } else {
+ savedWebViewState.clear()
+ Logger.w(
+ LOG_TAG,
+ "AndroidView.factory loadUrl (RESET) id=$instanceId url=${shortUrl(authorizeUrl)}",
+ )
+ loadUrl(
+ authorizeUrl,
+ mapOf("Accept-Language" to "$languageTag,$lang;q=0.9,en;q=0.8"),
+ )
+ }
+ webView = this
+ }
+ },
+ modifier = Modifier.fillMaxSize(),
+ update = { wv ->
+ val clientChanged = wv.webViewClient !== client
+ val darkBefore = wv.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
+ applyYandexDarkSettingsIfNeeded(wv, darkTheme)
+ if (clientChanged) {
+ Logger.i(
+ LOG_TAG,
+ "AndroidView.update reassign client id=$instanceId " +
+ "darkBefore=$darkBefore darkTheme=$darkTheme " +
+ "url=${shortUrl(wv.url)}",
+ )
+ wv.webViewClient = client
+ }
+ webView = wv
+ updateCanGoBack(wv.canGoBack())
+ },
+ onRelease = { wv ->
+ Logger.i(
+ LOG_TAG,
+ "AndroidView.onRelease id=$instanceId " +
+ "wv=${Integer.toHexString(System.identityHashCode(wv))} " +
+ "url=${shortUrl(wv.url)} historySize=${wv.copyBackForwardList().size} " +
+ "stack=${Throwable().stackTraceToString().lineSequence().drop(1).take(10).joinToString(" ← ")}",
+ )
+ savedWebViewState.clear()
+ wv.saveState(savedWebViewState)
+ Logger.i(
+ LOG_TAG,
+ "AndroidView.onRelease saved id=$instanceId " +
+ "bundleEmpty=${savedWebViewState.isEmpty} bundleSize=${savedWebViewState.size()}",
+ )
+ },
+ )
+
+ AnimatedVisibility(
+ visible = showLoadingOverlay,
+ enter = fadeIn(tween(0)),
+ exit = fadeOut(tween(LOADING_FADE_MS)),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(fallbackColor),
+ contentAlignment = Alignment.Center,
+ ) {
+ LoadingIndicator()
+ }
+ }
+ }
+}
+
+@Composable
+private fun ApplyYandexWebViewSystemBars(
+ chromeColor: Color,
+ darkTheme: Boolean,
+ restoreSurfaceColor: Color,
+) {
+ val view = LocalView.current
+ val lightIcons = chromeColor.luminance() > 0.5f
+ val chromeArgb = chromeColor.toArgb()
+ val restoreArgb = restoreSurfaceColor.toArgb()
+
+ // Match page chrome while OAuth is open…
+ SideEffect {
+ val window = (view.context as? Activity)?.window ?: return@SideEffect
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ window.isNavigationBarContrastEnforced = false
+ }
+ window.decorView.setBackgroundColor(chromeArgb)
+ @Suppress("DEPRECATION")
+ window.statusBarColor = chromeArgb
+ @Suppress("DEPRECATION")
+ window.navigationBarColor = chromeArgb
+ WindowCompat.getInsetsController(window, window.decorView).apply {
+ isAppearanceLightNavigationBars = lightIcons
+ isAppearanceLightStatusBars = lightIcons
+ }
+ }
+
+ // …and restore app theme bars when leaving (SideEffect alone won't re-run if theme deps unchanged).
+ DisposableEffect(darkTheme, restoreArgb) {
+ onDispose {
+ val window = (view.context as? Activity)?.window ?: return@onDispose
+ WindowCompat.getInsetsController(window, view).apply {
+ isAppearanceLightStatusBars = !darkTheme
+ isAppearanceLightNavigationBars = !darkTheme
+ }
+ @Suppress("DEPRECATION")
+ window.statusBarColor = AndroidColor.TRANSPARENT
+ @Suppress("DEPRECATION")
+ window.navigationBarColor = AndroidColor.TRANSPARENT
+ window.decorView.setBackgroundColor(restoreArgb)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ window.isNavigationBarContrastEnforced = false
+ }
+ }
+ }
+}
+
+private fun Context.findActivity(): Activity? {
+ var ctx: Context? = this
+ while (ctx is ContextWrapper) {
+ if (ctx is Activity) return ctx
+ ctx = ctx.baseContext
+ }
+ return null
+}
+
+private fun clearYandexWebViewCookies() {
+ val cookieManager = CookieManager.getInstance()
+ cookieManager.setAcceptCookie(true)
+ cookieManager.removeAllCookies(null)
+ cookieManager.flush()
+ Logger.i(LOG_TAG, "cleared all WebView cookies for Yandex re-auth")
+}
+
+private fun seedYandexThemeCookies(darkTheme: Boolean) {
+ val theme = if (darkTheme) "dark" else "light"
+ val cookieManager = CookieManager.getInstance()
+ cookieManager.setAcceptCookie(true)
+ val hosts = listOf(
+ "https://yandex.ru",
+ "https://yandex.com",
+ "https://passport.yandex.ru",
+ "https://passport.yandex.com",
+ "https://oauth.yandex.ru",
+ "https://oauth.yandex.com",
+ )
+ for (host in hosts) {
+ cookieManager.setCookie(host, "color_scheme=$theme; path=/")
+ cookieManager.setCookie(host, "theme=$theme; path=/")
+ cookieManager.setCookie(host, "yh=Theme=$theme; path=/")
+ }
+ cookieManager.flush()
+}
+
+/**
+ * Applies force-dark / algorithmic darkening only when [darkTheme] changed.
+ * Re-applying the same force-dark value can reload the page and wipe in-progress OAuth UI.
+ */
+@Suppress("DEPRECATION")
+private fun applyYandexDarkSettingsIfNeeded(webView: WebView, darkTheme: Boolean) {
+ val previous = webView.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
+ if (previous == darkTheme) return
+ Logger.w(
+ LOG_TAG,
+ "applyDarkSettings CHANGE previous=$previous → $darkTheme " +
+ "wv=${Integer.toHexString(System.identityHashCode(webView))} url=${shortUrl(webView.url)}",
+ )
+ webView.setTag(TAG_APPLIED_DARK_THEME, darkTheme)
+ val settings = webView.settings
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
+ WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, darkTheme)
+ }
+ if (darkTheme && WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
+ WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_ON)
+ } else if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
+ WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
+ }
+}
+
+private suspend fun pageScrollY(webView: WebView): Double =
+ suspendCancellableCoroutine { cont ->
+ webView.evaluateJavascript(PAGE_SCROLL_Y_JS) { raw ->
+ val value = raw?.trim()?.removeSurrounding("\"")?.toDoubleOrNull() ?: 0.0
+ cont.resume(value)
+ }
+ }
+
+/**
+ * Samples the first visible pixel row of the WebView (mid-x).
+ * Caller must only invoke this when the document is scrolled to the page start
+ * and the activity is resumed (window still has a surface).
+ */
+private suspend fun sampleTopRowColor(webView: WebView): Color? {
+ if (webView.width <= 0 || webView.height <= 0) return null
+ val activity = webView.context.findActivity() ?: return null
+ if (activity is androidx.lifecycle.LifecycleOwner &&
+ !activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
+ ) {
+ return null
+ }
+ val window = activity.window ?: return null
+ val decor = window.decorView
+ if (!decor.isAttachedToWindow || decor.width <= 0 || decor.height <= 0) return null
+ val loc = IntArray(2)
+ webView.getLocationInWindow(loc)
+ val x = loc[0] + webView.width / 2
+ val y = loc[1]
+ if (x < 0 || y < 0) return null
+ val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
+ val src = Rect(x, y, x + 1, y + 1)
+ return suspendCancellableCoroutine { cont ->
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ bitmap.recycle()
+ cont.resume(null)
+ return@suspendCancellableCoroutine
+ }
+ try {
+ PixelCopy.request(
+ window,
+ src,
+ bitmap,
+ { result ->
+ if (result == PixelCopy.SUCCESS) {
+ val pixel = bitmap.getPixel(0, 0)
+ cont.resume(Color(pixel))
+ } else {
+ cont.resume(null)
+ }
+ bitmap.recycle()
+ },
+ Handler(Looper.getMainLooper()),
+ )
+ } catch (e: IllegalArgumentException) {
+ // e.g. "Window doesn't have a backing surface!" after ON_PAUSE/ON_STOP
+ Logger.w(LOG_TAG, "PixelCopy skipped: ${e.message}")
+ bitmap.recycle()
+ cont.resume(null)
+ }
+ }
+}
+
+/**
+ * Keep Yandex ID / OAuth / captcha flows in the WebView; open everything else externally.
+ */
+internal fun isYandexAuthNavigation(url: String): Boolean {
+ if (url.startsWith("fromchat://", ignoreCase = true)) return true
+ val uri = Uri.parse(url)
+ val host = uri.host?.lowercase() ?: return false
+ val path = uri.path.orEmpty().lowercase()
+
+ if (host == "yandex.ru" || host == "www.yandex.ru" || host == "ya.ru" || host == "www.ya.ru") {
+ return path.contains("captcha") ||
+ path.startsWith("/auth") ||
+ path.startsWith("/showcaptcha") ||
+ path.startsWith("/checkcaptcha")
+ }
+
+ return host == "oauth.yandex.com" ||
+ host == "oauth.yandex.ru" ||
+ host.endsWith(".oauth.yandex.com") ||
+ host.endsWith(".oauth.yandex.ru") ||
+ host == "passport.yandex.ru" ||
+ host == "passport.yandex.com" ||
+ host.endsWith(".passport.yandex.ru") ||
+ host.endsWith(".passport.yandex.com") ||
+ host.startsWith("auth.yandex.") ||
+ host.startsWith("login.yandex.") ||
+ host.startsWith("id.yandex.") ||
+ host == "sso.passport.yandex.ru" ||
+ host == "captcha.yandex.net" ||
+ host.endsWith(".captcha.yandex.net") ||
+ (host.contains("captcha") && host.contains("yandex"))
+}
diff --git a/app/shared/src/commonMain/composeResources/drawable/ic_yandex.xml b/app/shared/src/commonMain/composeResources/drawable/ic_yandex.xml
new file mode 100644
index 0000000..06dda77
--- /dev/null
+++ b/app/shared/src/commonMain/composeResources/drawable/ic_yandex.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
index fb6e188..ed5c0cc 100644
--- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -51,6 +51,12 @@
Мы войдём в аккаунт или создадим новый.
Подтвердите пароль
Введите тот же пароль ещё раз.
+ Войдите через Яндекс ID
+ Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. Номер телефона нам недоступен — мы видим только имя, фамилию и пол. Эти данные мы не сохраняем — они нужны только для защиты и чтобы заранее заполнить поля на следующем шаге (не бойтесь, вы сможете подправить данные перед отправкой).
+ Продолжить через Яндекс ID
+ Яндекс ID
+ Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.
+ Вход через Яндекс ID отменён или не удался.
Чаты
Контакты
Профиль
@@ -418,6 +424,14 @@
Придётся войти снова.
Удалить аккаунт
Безвозвратно удалить аккаунт и данные
+ Сменить Яндекс ID
+ Привязать другой аккаунт Яндекса
+ Сменить Яндекс ID?
+ Это не удаляет ваш аккаунт FromChat. Предыдущий Яндекс ID будет освобождён, а вместо него привяжется тот, с которым вы войдёте.
+ Продолжить
+ Яндекс ID обновлён
+ Аккаунт теперь привязан к Яндекс ID, с которым вы только что вошли.
+ Готово
Удалить аккаунт?
Это нельзя отменить.
Аккаунт удалён
diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml
index cb8d879..d35a4b0 100644
--- a/app/shared/src/commonMain/composeResources/values/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values/strings.xml
@@ -58,6 +58,12 @@
We will sign you in or create a new account.
Confirm your password
Enter the same password again.
+ Sign in with Yandex ID
+ This helps us fight malicious bots and meet Russian legal requirements. We can’t access your phone number — we only see your first name, last name, and gender. We don’t store that data; it’s only used for protection and to pre-fill the next step (don’t worry — you can edit everything before submitting).
+ Continue with Yandex ID
+ Yandex ID
+ This server returned an unexpected Yandex app id. Update the app or contact support.
+ Yandex sign-in was cancelled or failed.
Chats
Contacts
@@ -446,6 +452,14 @@
You will need to sign in again.
Delete account
Permanently delete your account and data
+ Change Yandex ID
+ Link a different Yandex account
+ Change Yandex ID?
+ This does not delete your FromChat account. Your previous Yandex ID will be freed, and the new one you sign in with will be linked instead.
+ Continue
+ Yandex ID updated
+ Your account is now linked to the Yandex ID you just signed in with.
+ Done
Delete account?
This cannot be undone.
Account deleted
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 1968105..ff2ac48 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
@@ -44,7 +44,12 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement
+import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient.logout
import ru.fromchat.api.ApiClient.persistSessionToStorage
import ru.fromchat.api.crypto.IdentityKeyManager
@@ -99,11 +104,21 @@ import ru.fromchat.api.schema.user.FcmTokenRequest
import ru.fromchat.api.schema.user.User
import ru.fromchat.api.schema.user.UsersSearchResponse
import ru.fromchat.api.schema.user.VerifyPasswordRequest
+import ru.fromchat.api.schema.user.auth.AuthPasswordStepRequest
+import ru.fromchat.api.schema.user.auth.AuthUsernameStepRequest
+import ru.fromchat.api.schema.user.auth.AuthUsernameStepResponse
+import ru.fromchat.api.schema.user.auth.AccountYandexResponse
+import ru.fromchat.api.schema.user.auth.ChangeYandexRequest
+import ru.fromchat.api.schema.user.auth.ChangeYandexResponse
import ru.fromchat.api.schema.user.auth.CheckAuthResponse
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
import ru.fromchat.api.schema.user.auth.LoginRequest
import ru.fromchat.api.schema.user.auth.LoginResponse
+import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
import ru.fromchat.api.schema.user.auth.RegisterRequest
+import ru.fromchat.api.schema.user.auth.YandexExchangeRequest
+import ru.fromchat.api.schema.user.auth.YandexExchangeResponse
+import ru.fromchat.api.schema.user.auth.YandexOAuthParams
import ru.fromchat.api.schema.user.devices.DeviceSessionInfo
import ru.fromchat.api.schema.user.devices.DevicesListResponse
import ru.fromchat.api.schema.user.keys.BackupBlobRequest
@@ -485,6 +500,60 @@ object ApiClient {
}
.body()
+ suspend fun authUsernameStep(username: String): AuthUsernameStepResponse =
+ httpProbe
+ .post("${ServerConfig.apiBaseUrl}/auth/steps/username") {
+ contentType(ContentType.Application.Json)
+ setBody(AuthUsernameStepRequest(username = username.trim()))
+ }
+ .body()
+
+ sealed interface AuthPasswordStepOutcome {
+ data class LoggedIn(val response: LoginResponse) : AuthPasswordStepOutcome
+ data class NeedsRegister(
+ val yandexRequired: Boolean,
+ val yandex: YandexOAuthParams?,
+ ) : AuthPasswordStepOutcome
+ }
+
+ suspend fun authPasswordStep(username: String, passwordDerived: String): AuthPasswordStepOutcome {
+ val raw = httpProbe
+ .post("${ServerConfig.apiBaseUrl}/auth/steps/password") {
+ contentType(ContentType.Application.Json)
+ setBody(
+ AuthPasswordStepRequest(
+ username = username.trim(),
+ password = passwordDerived,
+ ),
+ )
+ }
+ .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) },
+ )
+ else -> AuthPasswordStepOutcome.LoggedIn(json.decodeFromJsonElement(LoginResponse.serializer(), raw))
+ }
+ }
+
+ suspend fun authYandexExchange(code: String, codeVerifier: String): YandexExchangeResponse =
+ httpProbe
+ .post("${ServerConfig.apiBaseUrl}/auth/yandex/exchange") {
+ contentType(ContentType.Application.Json)
+ setBody(YandexExchangeRequest(code = code, code_verifier = codeVerifier))
+ }
+ .body()
+
+ suspend fun authRegisterConfirm(request: RegisterConfirmRequest): LoginResponse =
+ httpProbe
+ .post("${ServerConfig.apiBaseUrl}/auth/steps/register/confirm") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }
+ .body()
+
/** Public pre-auth lookup; uses probe client so an existing session token is not sent. */
suspend fun checkUsername(username: String): CheckUsernameResponse =
httpProbe
@@ -1478,6 +1547,19 @@ object ApiClient {
}
}
+ suspend fun getAccountYandex(): AccountYandexResponse =
+ http
+ .get("${ServerConfig.apiBaseUrl}/account/yandex")
+ .body()
+
+ suspend fun changeAccountYandex(registrationProof: String): ChangeYandexResponse =
+ http
+ .post("${ServerConfig.apiBaseUrl}/account/yandex") {
+ contentType(ContentType.Application.Json)
+ setBody(ChangeYandexRequest(registration_proof = registrationProof))
+ }
+ .body()
+
suspend fun verifyPasswordDerived(passwordDerived: String) {
http.post("${ServerConfig.apiBaseUrl}/verify-password") {
contentType(ContentType.Application.Json)
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
new file mode 100644
index 0000000..32eec47
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/AuthSteps.kt
@@ -0,0 +1,73 @@
+package ru.fromchat.api.schema.user.auth
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class AuthUsernameStepRequest(
+ val username: String,
+)
+
+@Serializable
+data class AuthUsernameStepResponse(
+ val ok: Boolean = true,
+ val exists: Boolean? = null,
+)
+
+@Serializable
+data class AuthPasswordStepRequest(
+ val username: String,
+ val password: String,
+)
+
+@Serializable
+data class YandexOAuthParams(
+ val client_id: String,
+ val redirect_uri: String,
+ val authorize_url: String,
+ val scope: String,
+)
+
+@Serializable
+data class AuthNeedsRegisterResponse(
+ val status: String,
+ val yandex_required: Boolean = false,
+ val yandex: YandexOAuthParams? = null,
+)
+
+@Serializable
+data class YandexExchangeRequest(
+ val code: String,
+ val code_verifier: String,
+)
+
+@Serializable
+data class YandexExchangeResponse(
+ val registration_proof: String,
+)
+
+@Serializable
+data class AccountYandexResponse(
+ val linked: Boolean = false,
+ val yandex: YandexOAuthParams? = null,
+)
+
+@Serializable
+data class ChangeYandexRequest(
+ val registration_proof: String,
+)
+
+@Serializable
+data class ChangeYandexResponse(
+ val status: String? = null,
+ val unchanged: Boolean = false,
+)
+
+@Serializable
+data class RegisterConfirmRequest(
+ val username: String,
+ val display_name: String,
+ val password: String,
+ val confirm_password: String,
+ val bio: String? = null,
+ val registration_proof: String? = null,
+)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/auth/yandex/YandexOAuth.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/auth/yandex/YandexOAuth.kt
new file mode 100644
index 0000000..10c8bc0
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/auth/yandex/YandexOAuth.kt
@@ -0,0 +1,166 @@
+package ru.fromchat.auth.yandex
+
+import korlibs.crypto.SHA256
+import kotlin.random.Random
+
+internal data class PkcePair(
+ val codeVerifier: String,
+ val codeChallenge: String,
+)
+
+internal fun generatePkcePair(): PkcePair {
+ val verifierBytes = ByteArray(32).also { Random.Default.nextBytes(it) }
+ val codeVerifier = base64UrlNoPad(verifierBytes)
+ val challenge = base64UrlNoPad(SHA256.digest(codeVerifier.encodeToByteArray()).bytes)
+ return PkcePair(codeVerifier = codeVerifier, codeChallenge = challenge)
+}
+
+private fun base64UrlNoPad(bytes: ByteArray): String {
+ val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
+ val out = StringBuilder((bytes.size * 4 + 2) / 3)
+ var i = 0
+ while (i < bytes.size) {
+ val b0 = bytes[i].toInt() and 0xff
+ val b1 = if (i + 1 < bytes.size) bytes[i + 1].toInt() and 0xff else 0
+ val b2 = if (i + 2 < bytes.size) bytes[i + 2].toInt() and 0xff else 0
+ out.append(alphabet[b0 shr 2])
+ out.append(alphabet[((b0 and 0x03) shl 4) or (b1 shr 4)])
+ if (i + 1 < bytes.size) {
+ out.append(alphabet[((b1 and 0x0f) shl 2) or (b2 shr 6)])
+ }
+ if (i + 2 < bytes.size) {
+ out.append(alphabet[b2 and 0x3f])
+ }
+ i += 3
+ }
+ return out.toString()
+}
+
+internal const val OFFICIAL_API_HOST = "api.fromchat.ru"
+
+/**
+ * Prod Yandex OAuth client id (identity-only app). When non-empty and the API host is
+ * [OFFICIAL_API_HOST], the server-supplied client_id must match this value.
+ */
+internal const val OFFICIAL_YANDEX_OAUTH_CLIENT_ID = ""
+
+internal const val YANDEX_OAUTH_REDIRECT_URI = "fromchat://oauth/yandex"
+
+internal fun isOfficialApiHost(serverIp: String): Boolean {
+ val host = serverIp.trim().lowercase().substringBefore("/").substringBefore(":")
+ return host == OFFICIAL_API_HOST
+}
+
+/**
+ * Returns the client_id to use, or null if the official host sent a mismatched id.
+ */
+internal fun resolveYandexClientId(serverClientId: String, serverIp: String): String? {
+ val trimmed = serverClientId.trim()
+ if (trimmed.isEmpty()) return null
+ if (!isOfficialApiHost(serverIp)) return trimmed
+ val pinned = OFFICIAL_YANDEX_OAUTH_CLIENT_ID.trim()
+ if (pinned.isEmpty()) return trimmed
+ return if (pinned == trimmed) trimmed else null
+}
+
+/**
+ * Yandex UI language follows the authorize host (`.ru` vs `.com`), not OAuth `lang`.
+ * Keep `lang` anyway; rewrite host so passport matches the app locale.
+ */
+internal fun yandexAuthorizeHostForLanguage(languageTag: String): String {
+ val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
+ return if (lang == "ru") "oauth.yandex.ru" else "oauth.yandex.com"
+}
+
+internal fun withYandexAuthorizeHost(authorizeUrl: String, languageTag: String): String {
+ val targetHost = yandexAuthorizeHostForLanguage(languageTag)
+ return Regex("""oauth\.yandex\.(com|ru)""", RegexOption.IGNORE_CASE)
+ .replace(authorizeUrl.trim()) { targetHost }
+}
+
+internal fun buildYandexAuthorizeUrl(
+ authorizeUrl: String,
+ clientId: String,
+ redirectUri: String,
+ scope: String,
+ codeChallenge: String,
+ languageTag: String = "en",
+ darkTheme: Boolean = false,
+): String {
+ val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
+ val base = withYandexAuthorizeHost(authorizeUrl, lang).trimEnd('?')
+ val theme = if (darkTheme) "dark" else "light"
+ val params = buildList {
+ add("response_type" to "code")
+ add("client_id" to clientId)
+ add("redirect_uri" to redirectUri)
+ if (scope.isNotBlank()) {
+ add("scope" to scope.trim())
+ }
+ add("code_challenge" to codeChallenge)
+ add("code_challenge_method" to "S256")
+ add("force_confirm" to "yes")
+ add("lang" to lang)
+ add("theme" to theme)
+ add("color_scheme" to theme)
+ }.joinToString("&") { (k, v) ->
+ "${encodeUrl(k)}=${encodeUrl(v)}"
+ }
+ return "$base?$params"
+}
+
+private fun encodeUrl(value: String): String = buildString(value.length) {
+ for (ch in value) {
+ when {
+ ch.isLetterOrDigit() || ch in "-_.~" -> append(ch)
+ else -> {
+ val bytes = ch.toString().encodeToByteArray()
+ for (b in bytes) {
+ append('%')
+ append(((b.toInt() shr 4) and 0xf).toString(16).uppercase())
+ append((b.toInt() and 0xf).toString(16).uppercase())
+ }
+ }
+ }
+ }
+}
+
+internal fun extractOAuthCode(redirectUrl: String): String? {
+ val uri = redirectUrl.trim()
+ if (!uri.startsWith(YANDEX_OAUTH_REDIRECT_URI, ignoreCase = true) &&
+ !uri.startsWith("fromchat://oauth/yandex?", ignoreCase = true)
+ ) {
+ return null
+ }
+ val query = uri.substringAfter('?', missingDelimiterValue = "")
+ if (query.isEmpty()) return null
+ return query.split('&').firstNotNullOfOrNull { part ->
+ val key = part.substringBefore('=')
+ val raw = part.substringAfter('=', missingDelimiterValue = "")
+ if (key == "code" && raw.isNotEmpty()) decodeUrl(raw) else null
+ }
+}
+
+private fun decodeUrl(value: String): String {
+ val bytes = ArrayList()
+ var i = 0
+ while (i < value.length) {
+ val c = value[i]
+ when {
+ c == '+' -> {
+ bytes.add(' '.code.toByte())
+ i++
+ }
+ c == '%' && i + 2 < value.length -> {
+ val hex = value.substring(i + 1, i + 3)
+ bytes.add(hex.toInt(16).toByte())
+ i += 3
+ }
+ else -> {
+ bytes.add(c.code.toByte())
+ i++
+ }
+ }
+ }
+ return bytes.toByteArray().decodeToString()
+}
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 851b33a..0079b3f 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.yandex.YandexOAuthNav
+import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
import ru.fromchat.ui.calls.CallOverlay
import ru.fromchat.ui.chat.panels.dm.DmChatRoute
import ru.fromchat.ui.chat.panels.dm.DmNav
@@ -98,6 +100,9 @@ import ru.fromchat.ui.main.settings.NotificationsScreen
import ru.fromchat.ui.main.settings.SettingsRoutes
import ru.fromchat.ui.main.settings.account.AccountScreen
import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen
+import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexConfirmScreen
+import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexDoneScreen
+import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexOAuthScreen
import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
import ru.fromchat.ui.main.settings.server.ServerConfigScreen
import ru.fromchat.ui.profile.EditProfileFocusField
@@ -457,6 +462,10 @@ fun App(
)
}
+ composable(YandexOAuthNav.ROUTE) {
+ YandexOAuthScreen()
+ }
+
composable("chat") {
MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
@@ -670,6 +679,26 @@ fun App(
)
}
+ settingsComposable(SettingsRoutes.AccountYandexFlow) {
+ ChangeYandexConfirmScreen(
+ onBack = { navController.navigateUp() },
+ )
+ }
+
+ settingsComposable(SettingsRoutes.AccountYandexOAuth) {
+ ChangeYandexOAuthScreen(
+ onBack = { navController.navigateUp() },
+ )
+ }
+
+ settingsComposable(SettingsRoutes.AccountYandexDone) {
+ ChangeYandexDoneScreen(
+ onDone = {
+ navController.popBackStack(SettingsRoutes.Account, inclusive = false)
+ },
+ )
+ }
+
settingsComposable(SettingsRoutes.Account) {
AccountScreen(
onBack = { navController.navigateUp() },
@@ -679,6 +708,7 @@ fun App(
}
},
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) },
+ onChangeYandexId = { navController.navigate(SettingsRoutes.AccountYandexFlow) },
onDeleteAccount = { navController.navigate(SettingsRoutes.AccountDeleteFlow) },
)
}
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
new file mode 100644
index 0000000..362cb35
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthRegisterDraft.kt
@@ -0,0 +1,31 @@
+package ru.fromchat.ui.auth
+
+import ru.fromchat.api.schema.user.auth.YandexOAuthParams
+
+/**
+ * Survives [AuthScreen] leaving composition when navigating to the Yandex OAuth route.
+ * Cleared on welcome / successful auth / explicit reset to username.
+ */
+internal object AuthRegisterDraft {
+ var username: String = ""
+ var password: String = ""
+ var confirmPassword: String = ""
+ var displayName: String = ""
+ var bio: String = ""
+ var yandexRequired: Boolean = false
+ var yandexParams: YandexOAuthParams? = null
+ var registrationProof: String? = null
+ var page: Int = 0
+
+ fun clear() {
+ username = ""
+ password = ""
+ confirmPassword = ""
+ displayName = ""
+ bio = ""
+ yandexRequired = false
+ yandexParams = null
+ registrationProof = 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 c44fe0f..e44b9cf 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
@@ -5,6 +5,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.SnackbarHostState
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
@@ -27,14 +28,15 @@ import ru.fromchat.api.local.db.clearAccountCacheOnLogout
import ru.fromchat.api.instance.ServerProbeResult
import ru.fromchat.api.instance.probeServer
import ru.fromchat.api.schema.core.ErrorResponse
-import ru.fromchat.api.schema.user.auth.LoginRequest
import ru.fromchat.api.schema.user.auth.LoginResponse
-import ru.fromchat.api.schema.user.auth.RegisterRequest
+import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
+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.register.confirmPasswordStepPage
import ru.fromchat.ui.auth.register.profileStepPage
+import ru.fromchat.ui.auth.yandex.yandexIdStepPage
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.TextCta
@@ -47,12 +49,16 @@ private enum class AuthFlowStep {
Username,
Password,
ConfirmPassword,
+ YandexId,
Profile,
}
internal sealed interface PasswordStepResult {
data object LoginSuccess : PasswordStepResult
- data object AdvanceToRegister : PasswordStepResult
+ data class NeedsRegister(
+ val yandexRequired: Boolean,
+ val yandex: YandexOAuthParams?,
+ ) : PasswordStepResult
data class WrongPassword(val message: String) : PasswordStepResult
data class RateLimited(val message: String) : PasswordStepResult
data class Error(val message: String, val cause: Throwable? = null) : PasswordStepResult
@@ -72,29 +78,6 @@ internal suspend fun probeCurrentServer() = runCatching {
}
}.getOrDefault(false)
-internal suspend fun authBranch(
- username: String,
- password: String,
- wrongPasswordMessage: String,
- rateLimitMessage: String,
- unexpectedError: String,
-) = login(
- username.trim(),
- password,
- wrongPasswordMessage,
- rateLimitMessage,
- unexpectedError,
-).let { result ->
- if (
- result is PasswordStepResult.WrongPassword &&
- !runCatching { ApiClient.checkUsername(username.trim()).exists }.getOrDefault(true)
- ) {
- PasswordStepResult.AdvanceToRegister
- } else {
- result
- }
-}
-
private suspend fun fullLogin(
username: String,
password: String,
@@ -123,20 +106,25 @@ private suspend fun fullLogin(
runCatching { ApiClient.refreshServerInstanceFingerprint() }
}
-private suspend fun login(
+internal suspend fun authPasswordStep(
username: String,
password: String,
wrongPasswordMessage: String,
rateLimitMessage: String,
unexpectedError: String,
) = try {
- fullLogin(username, password.trim()) {
- ApiClient.loginRequest(
- LoginRequest(username, deriveAuthSecret(username, password.trim())),
+ val derived = deriveAuthSecret(username.trim(), password.trim())
+ when (val outcome = ApiClient.authPasswordStep(username.trim(), derived)) {
+ is ApiClient.AuthPasswordStepOutcome.LoggedIn -> {
+ fullLogin(username.trim(), password.trim()) { outcome.response }
+ PasswordStepResult.LoginSuccess
+ }
+
+ is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> PasswordStepResult.NeedsRegister(
+ yandexRequired = outcome.yandexRequired,
+ yandex = outcome.yandex,
)
}
-
- PasswordStepResult.LoginSuccess
} catch (e: ClientRequestException) {
when (e.response.status.value) {
401 -> PasswordStepResult.WrongPassword(
@@ -158,27 +146,23 @@ internal suspend fun register(
displayName: String,
password: String,
bio: String,
+ registrationProof: String?,
unexpectedError: String,
) = try {
- if (runCatching { ApiClient.checkUsername(username.trim()).exists }.getOrDefault(false)) {
- RegisterResult.UsernameTaken
- } else {
- fullLogin(username.trim(), password.trim()) {
- val derived = deriveAuthSecret(username.trim(), password.trim())
-
- ApiClient.registerRequest(
- RegisterRequest(
- username = username.trim(),
- display_name = displayName.trim(),
- password = derived,
- confirm_password = derived,
- bio = bio.trim().takeIf { it.isNotEmpty() },
- ),
- )
- }
-
- RegisterResult.Success
+ fullLogin(username.trim(), password.trim()) {
+ val derived = deriveAuthSecret(username.trim(), password.trim())
+ ApiClient.authRegisterConfirm(
+ RegisterConfirmRequest(
+ username = username.trim(),
+ display_name = displayName.trim(),
+ password = derived,
+ confirm_password = derived,
+ bio = bio.trim().takeIf { it.isNotEmpty() },
+ registration_proof = registrationProof,
+ ),
+ )
}
+ RegisterResult.Success
} catch (e: ClientRequestException) {
if (e.response.status.value == 400 && isUsernameTakenError(e)) {
RegisterResult.UsernameTaken
@@ -225,11 +209,26 @@ fun AuthScreen(
val snackbarHostState = remember { SnackbarHostState() }
val flowState = rememberExpressiveStepFlow(AuthFlowStep.entries.size)
- var username by remember { mutableStateOf("") }
- var password by remember { mutableStateOf("") }
- var confirmPassword by remember { mutableStateOf("") }
- var displayName by remember { mutableStateOf("") }
- var bio by remember { mutableStateOf("") }
+ var username by remember { mutableStateOf(AuthRegisterDraft.username) }
+ var password by remember { mutableStateOf(AuthRegisterDraft.password) }
+ var confirmPassword by remember { mutableStateOf(AuthRegisterDraft.confirmPassword) }
+ var displayName by remember { mutableStateOf(AuthRegisterDraft.displayName) }
+ var bio by remember { mutableStateOf(AuthRegisterDraft.bio) }
+ var yandexRequired by remember { mutableStateOf(AuthRegisterDraft.yandexRequired) }
+ var yandexParams by remember { mutableStateOf(AuthRegisterDraft.yandexParams) }
+ var registrationProof by remember { mutableStateOf(AuthRegisterDraft.registrationProof) }
+
+ fun persistDraft() {
+ AuthRegisterDraft.username = username
+ AuthRegisterDraft.password = password
+ AuthRegisterDraft.confirmPassword = confirmPassword
+ AuthRegisterDraft.displayName = displayName
+ AuthRegisterDraft.bio = bio
+ AuthRegisterDraft.yandexRequired = yandexRequired
+ AuthRegisterDraft.yandexParams = yandexParams
+ AuthRegisterDraft.registrationProof = registrationProof
+ AuthRegisterDraft.page = flowState.pagerState.currentPage
+ }
fun snackbar(text: String, cause: Throwable? = null) {
scope.showLoggedSnackbar(
@@ -240,44 +239,86 @@ fun AuthScreen(
)
}
+ val wrappedAuthSuccess: () -> Unit = {
+ AuthRegisterDraft.clear()
+ onAuthSuccess()
+ }
+
+ val wrappedBackToWelcome: () -> Unit = {
+ AuthRegisterDraft.clear()
+ onBackToWelcome()
+ }
+
val resetToUsername: () -> Unit = {
username = ""
password = ""
confirmPassword = ""
displayName = ""
bio = ""
+ yandexRequired = false
+ yandexParams = null
+ registrationProof = null
+ AuthRegisterDraft.clear()
flowState.resetPredictiveState()
scope.launch {
flowState.pagerState.animateScrollToPage(AuthFlowStep.Username.ordinal)
}
}
- LaunchedEffect(Unit) {
- username = ""
- password = ""
- confirmPassword = ""
- displayName = ""
- bio = ""
+ DisposableEffect(Unit) {
+ onDispose { persistDraft() }
+ }
+
+ LaunchedEffect(username, password, confirmPassword, displayName, bio, yandexRequired, yandexParams, registrationProof) {
+ persistDraft()
}
LaunchedEffect(flowState.pagerState) {
+ val restored = AuthRegisterDraft.page
+ if (restored in 1 until AuthFlowStep.entries.size &&
+ flowState.pagerState.currentPage != restored
+ ) {
+ flowState.pagerState.scrollToPage(restored)
+ }
var settledPage = flowState.pagerState.currentPage
snapshotFlow { flowState.pagerState.currentPage }
.collect { page ->
+ AuthRegisterDraft.page = page
+ if (page == AuthFlowStep.YandexId.ordinal && !yandexRequired) {
+ val target = if (page > settledPage) {
+ AuthFlowStep.Profile.ordinal
+ } else {
+ AuthFlowStep.ConfirmPassword.ordinal
+ }
+ settledPage = target
+ flowState.pagerState.scrollToPage(target)
+ return@collect
+ }
if (page < settledPage) {
when (page) {
AuthFlowStep.Username.ordinal -> {
password = ""
confirmPassword = ""
+ yandexRequired = false
+ yandexParams = null
+ registrationProof = null
}
AuthFlowStep.Password.ordinal -> {
password = ""
confirmPassword = ""
+ yandexRequired = false
+ yandexParams = null
+ registrationProof = null
}
AuthFlowStep.ConfirmPassword.ordinal -> {
- confirmPassword = ""
+ // Keep confirm password when returning from Yandex ID / OAuth.
+ registrationProof = null
+ }
+
+ AuthFlowStep.YandexId.ordinal -> {
+ registrationProof = null
}
}
}
@@ -285,6 +326,7 @@ fun AuthScreen(
}
}
+ val yandexStep = yandexParams
ExpressiveStepFlowScaffold(
flowState = flowState,
pages = listOf(
@@ -300,8 +342,11 @@ fun AuthScreen(
username = username,
password = password,
onPasswordChange = { password = it },
- onLoginSuccess = onAuthSuccess,
- onRegister = {
+ onLoginSuccess = wrappedAuthSuccess,
+ onNeedsRegister = { required, params ->
+ yandexRequired = required
+ yandexParams = params
+ registrationProof = null
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
},
onSnackbar = ::snackbar,
@@ -311,10 +356,34 @@ fun AuthScreen(
onConfirmPasswordChange = { confirmPassword = it },
password = password,
onContinue = {
- flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
+ if (yandexRequired && yandexParams != null) {
+ flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
+ } else {
+ flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
+ }
},
onSnackbar = ::snackbar,
),
+ if (yandexStep != null) {
+ yandexIdStepPage(
+ yandex = yandexStep,
+ onProof = { proof ->
+ registrationProof = proof
+ flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
+ },
+ onSnackbar = ::snackbar,
+ )
+ } else {
+ confirmPasswordStepPage(
+ confirmPassword = confirmPassword,
+ onConfirmPasswordChange = { confirmPassword = it },
+ password = password,
+ onContinue = {
+ flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
+ },
+ onSnackbar = ::snackbar,
+ )
+ },
profileStepPage(
username = username,
displayName = displayName,
@@ -322,12 +391,13 @@ fun AuthScreen(
bio = bio,
onBioChange = { bio = it },
password = password,
- onRegisterSuccess = onAuthSuccess,
+ registrationProof = registrationProof,
+ onRegisterSuccess = wrappedAuthSuccess,
onUsernameTaken = resetToUsername,
onSnackbar = ::snackbar,
),
),
snackbarHostState = snackbarHostState,
- onBackAtFirstPage = onBackToWelcome,
+ onBackAtFirstPage = wrappedBackToWelcome,
)
}
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 84ec93f..80998c6 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.YandexOAuthParams
import ru.fromchat.ui.components.ActionButton
import ru.fromchat.ui.components.ExpressiveHeroSpec
import ru.fromchat.ui.components.ExpressiveStepLazyListIndices
@@ -53,7 +54,7 @@ internal fun passwordStepPage(
password: String,
onPasswordChange: (String) -> Unit,
onLoginSuccess: () -> Unit,
- onRegister: suspend () -> Unit,
+ onNeedsRegister: suspend (yandexRequired: Boolean, yandex: YandexOAuthParams?) -> Unit,
onSnackbar: (String, Throwable?) -> Unit,
): ExpressiveStepPage {
val scope = rememberCoroutineScope()
@@ -121,7 +122,7 @@ internal fun passwordStepPage(
try {
when (
- val result = authBranch(
+ val result = authPasswordStep(
username = username,
password = password,
wrongPasswordMessage = wrongPassword,
@@ -133,8 +134,8 @@ internal fun passwordStepPage(
onLoginSuccess()
}
- is PasswordStepResult.AdvanceToRegister -> {
- onRegister()
+ is PasswordStepResult.NeedsRegister -> {
+ onNeedsRegister(result.yandexRequired, result.yandex)
}
is PasswordStepResult.WrongPassword -> {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/UsernameStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/UsernameStep.kt
index 4f13c80..d08ad13 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/UsernameStep.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/UsernameStep.kt
@@ -17,12 +17,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import io.ktor.client.call.body
+import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
+import ru.fromchat.api.ApiClient
+import ru.fromchat.api.schema.core.ErrorResponse
import ru.fromchat.auth_server_connect_failed
import ru.fromchat.auth_step_username_body
import ru.fromchat.auth_step_username_title
+import ru.fromchat.error_unexpected
import ru.fromchat.fill_all_fields
import ru.fromchat.settings_next
import ru.fromchat.ui.components.ActionButton
@@ -55,6 +60,7 @@ internal fun usernameStepPage(
val fillAll = stringResource(Res.string.fill_all_fields)
val usernameLenError = stringResource(Res.string.username_length_error)
val serverFail = stringResource(Res.string.auth_server_connect_failed)
+ val unexpected = stringResource(Res.string.error_unexpected)
val nextLabel = stringResource(Res.string.settings_next)
return ExpressiveStepPage(
@@ -109,7 +115,21 @@ internal fun usernameStepPage(
if (!probeCurrentServer()) {
onSnackbar(serverFail, null)
} else {
- onContinue()
+ try {
+ ApiClient.authUsernameStep(trimmed)
+ onContinue()
+ } catch (e: ClientRequestException) {
+ val detail = if (e.response.status.value == 400) {
+ runCatching { e.response.body().detail }
+ .getOrNull()
+ ?.ifBlank { null }
+ } else {
+ null
+ }
+ onSnackbar(detail ?: unexpected, e)
+ } catch (e: Exception) {
+ onSnackbar(unexpected, e)
+ }
}
} finally {
busy = false
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 10ff070..d2648b5 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
@@ -55,6 +55,7 @@ internal fun profileStepPage(
bio: String,
onBioChange: (String) -> Unit,
password: String,
+ registrationProof: String?,
onRegisterSuccess: () -> Unit,
onUsernameTaken: () -> Unit,
onSnackbar: (String, Throwable?) -> Unit,
@@ -147,6 +148,7 @@ internal fun profileStepPage(
displayName = displayName.trim(),
password = password,
bio = bio.trim(),
+ registrationProof = registrationProof,
unexpectedError = unexpected,
)
) {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/yandex/YandexIdStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/yandex/YandexIdStep.kt
new file mode 100644
index 0000000..f38cef9
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/yandex/YandexIdStep.kt
@@ -0,0 +1,130 @@
+package ru.fromchat.ui.auth.yandex
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialShapes
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.text.intl.Locale
+import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.compose.resources.vectorResource
+import ru.fromchat.Res
+import ru.fromchat.api.schema.user.auth.YandexOAuthParams
+import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
+import ru.fromchat.auth.yandex.buildYandexAuthorizeUrl
+import ru.fromchat.auth.yandex.generatePkcePair
+import ru.fromchat.auth.yandex.resolveYandexClientId
+import ru.fromchat.auth_step_yandex_body
+import ru.fromchat.auth_step_yandex_cta
+import ru.fromchat.auth_step_yandex_title
+import ru.fromchat.auth_yandex_client_mismatch
+import ru.fromchat.config.Settings
+import ru.fromchat.error_unexpected
+import ru.fromchat.ic_yandex
+import ru.fromchat.ui.LocalNavController
+import ru.fromchat.ui.components.ActionButton
+import ru.fromchat.ui.components.ExpressiveHeroSpec
+import ru.fromchat.ui.components.ExpressiveStepPage
+import ru.fromchat.ui.components.ExpressiveStepPageHeader
+import ru.fromchat.ui.components.Text
+import ru.fromchat.ui.isAppInDarkTheme
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+internal fun yandexIdStepPage(
+ yandex: YandexOAuthParams,
+ onProof: suspend (String) -> Unit,
+ onSnackbar: (String, Throwable?) -> Unit,
+): ExpressiveStepPage {
+ val navController = LocalNavController.current
+ val colorScheme = MaterialTheme.colorScheme
+ var busy by remember { mutableStateOf(false) }
+ val languageTag = Locale.current.toLanguageTag()
+ val darkTheme = isAppInDarkTheme()
+ val onProofState = rememberUpdatedState(onProof)
+ val onSnackbarState = rememberUpdatedState(onSnackbar)
+ val yandexIcon = vectorResource(Res.drawable.ic_yandex)
+
+ val title = stringResource(Res.string.auth_step_yandex_title)
+ val body = stringResource(Res.string.auth_step_yandex_body)
+ val cta = stringResource(Res.string.auth_step_yandex_cta)
+ val clientMismatch = stringResource(Res.string.auth_yandex_client_mismatch)
+ val unexpected = stringResource(Res.string.error_unexpected)
+
+ LaunchedEffect(navController) {
+ val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
+ handle.getStateFlow(YandexOAuthNav.RESULT_PROOF, null).collect { proof ->
+ if (proof == null) return@collect
+ handle.remove(YandexOAuthNav.RESULT_PROOF)
+ busy = true
+ try {
+ onProofState.value(proof)
+ } finally {
+ busy = false
+ }
+ }
+ }
+
+ LaunchedEffect(navController) {
+ val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
+ handle.getStateFlow(YandexOAuthNav.RESULT_ERROR, null).collect { message ->
+ if (message == null) return@collect
+ handle.remove(YandexOAuthNav.RESULT_ERROR)
+ onSnackbarState.value(message, null)
+ }
+ }
+
+ return ExpressiveStepPage(
+ hero = ExpressiveHeroSpec(
+ icon = yandexIcon,
+ polygon = MaterialShapes.Cookie9Sided.normalized(),
+ containerColor = colorScheme.primaryContainer,
+ contentColor = colorScheme.onPrimaryContainer,
+ ),
+ content = {
+ ExpressiveStepPageHeader(title = title, body = body)
+ },
+ button = {
+ ActionButton(
+ onClick = {
+ if (busy) return@ActionButton
+ val serverIp = runCatching { Settings.serverConfig.serverIp }.getOrElse {
+ onSnackbar(unexpected, it)
+ return@ActionButton
+ }
+ val clientId = resolveYandexClientId(yandex.client_id, serverIp)
+ if (clientId == null) {
+ onSnackbar(clientMismatch, null)
+ return@ActionButton
+ }
+ val pkce = generatePkcePair()
+ YandexOAuthNav.pending = YandexOAuthNav.Session(
+ authorizeUrl = buildYandexAuthorizeUrl(
+ authorizeUrl = yandex.authorize_url,
+ clientId = clientId,
+ redirectUri = yandex.redirect_uri.ifBlank { YANDEX_OAUTH_REDIRECT_URI },
+ scope = yandex.scope,
+ codeChallenge = pkce.codeChallenge,
+ languageTag = languageTag,
+ darkTheme = darkTheme,
+ ),
+ codeVerifier = pkce.codeVerifier,
+ )
+ navController.navigate(YandexOAuthNav.ROUTE)
+ },
+ enabled = !busy,
+ loading = busy,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(cta)
+ }
+ },
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthNav.kt
new file mode 100644
index 0000000..070fc24
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthNav.kt
@@ -0,0 +1,34 @@
+package ru.fromchat.ui.auth.yandex
+
+import androidx.compose.runtime.saveable.listSaver
+import kotlin.concurrent.Volatile
+
+/**
+ * Root [androidx.navigation.NavController] route for the Yandex OAuth WebView.
+ * Session is staged in [pending] before [navigate]; PKCE verifier must not go in the route.
+ * Prefer [SessionSaver] / rememberSaveable on the OAuth screen so pause/recreate keeps PKCE.
+ */
+internal object YandexOAuthNav {
+ const val ROUTE = "yandexOAuth"
+ const val RESULT_PROOF = "yandex_registration_proof"
+ const val RESULT_ERROR = "yandex_oauth_error"
+
+ data class Session(
+ val authorizeUrl: String,
+ val codeVerifier: String,
+ )
+
+ val SessionSaver = listSaver(
+ save = { session ->
+ if (session == null) emptyList()
+ else listOf(session.authorizeUrl, session.codeVerifier)
+ },
+ restore = { saved ->
+ if (saved.size < 2) null
+ else Session(authorizeUrl = saved[0], codeVerifier = saved[1])
+ },
+ )
+
+ @Volatile
+ var pending: Session? = null
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthScreen.kt
new file mode 100644
index 0000000..33ac0bb
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthScreen.kt
@@ -0,0 +1,171 @@
+package ru.fromchat.ui.auth.yandex
+
+import androidx.compose.animation.AnimatedVisibility
+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.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+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.rememberCoroutineScope
+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 androidx.compose.ui.unit.dp
+import io.ktor.client.call.body
+import io.ktor.client.plugins.ClientRequestException
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import ru.fromchat.Logger
+import ru.fromchat.Res
+import ru.fromchat.api.ApiClient
+import ru.fromchat.api.schema.core.ErrorResponse
+import ru.fromchat.auth_yandex_failed
+import ru.fromchat.back
+import ru.fromchat.ui.LocalNavController
+import ru.fromchat.ui.isAppInDarkTheme
+
+private const val LOG_TAG = "YandexOAuthScreen"
+
+@Composable
+internal fun YandexOAuthScreen() {
+ val navController = LocalNavController.current
+ val scope = rememberCoroutineScope()
+ // Survive Activity recreate / process restore — in-memory [YandexOAuthNav.pending] alone does not.
+ var session by rememberSaveable(stateSaver = YandexOAuthNav.SessionSaver) {
+ mutableStateOf(YandexOAuthNav.pending)
+ }
+ val fallbackColor = MaterialTheme.colorScheme.background
+ var chromeColor by remember { mutableStateOf(fallbackColor) }
+ var busy by remember { mutableStateOf(false) }
+ var webViewCanGoBack by remember { mutableStateOf(false) }
+ val failedMessage = stringResource(Res.string.auth_yandex_failed)
+ val backLabel = stringResource(Res.string.back)
+ val darkTheme = isAppInDarkTheme()
+ val screenId = remember { (100000..999999).random().toString(16) }
+
+ DisposableEffect(screenId) {
+ Logger.i(
+ LOG_TAG,
+ "compose enter id=$screenId sessionNull=${session == null} " +
+ "pendingNull=${YandexOAuthNav.pending == null} darkTheme=$darkTheme " +
+ "route=${navController.currentBackStackEntry?.destination?.route}",
+ )
+ onDispose {
+ Logger.i(LOG_TAG, "compose dispose id=$screenId")
+ }
+ }
+
+ LaunchedEffect(session) {
+ if (session == null) {
+ Logger.w(LOG_TAG, "session null → popBackStack id=$screenId")
+ navController.popBackStack()
+ } else {
+ YandexOAuthNav.pending = session
+ Logger.d(LOG_TAG, "session kept id=$screenId urlLen=${session!!.authorizeUrl.length}")
+ }
+ }
+
+ val active = session ?: return
+
+ fun finishWithProof(proof: String) {
+ Logger.i(LOG_TAG, "finishWithProof id=$screenId")
+ YandexOAuthNav.pending = null
+ navController.previousBackStackEntry
+ ?.savedStateHandle
+ ?.set(YandexOAuthNav.RESULT_PROOF, proof)
+ navController.popBackStack()
+ }
+
+ fun finishWithError(message: String) {
+ Logger.w(LOG_TAG, "finishWithError id=$screenId message=$message")
+ YandexOAuthNav.pending = null
+ navController.previousBackStackEntry
+ ?.savedStateHandle
+ ?.set(YandexOAuthNav.RESULT_ERROR, message)
+ navController.popBackStack()
+ }
+
+ fun cancel() {
+ if (busy) return
+ Logger.i(LOG_TAG, "cancel id=$screenId")
+ YandexOAuthNav.pending = null
+ navController.popBackStack()
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(chromeColor),
+ ) {
+ YandexOAuthWebView(
+ authorizeUrl = active.authorizeUrl,
+ languageTag = Locale.current.toLanguageTag(),
+ darkTheme = darkTheme,
+ fallbackColor = fallbackColor,
+ onPageBackgroundColor = { chromeColor = it },
+ onHistoryBackAvailabilityChanged = { webViewCanGoBack = it },
+ onCode = { code ->
+ if (busy) return@YandexOAuthWebView
+ scope.launch {
+ busy = true
+ try {
+ val proof = ApiClient.authYandexExchange(code, active.codeVerifier).registration_proof
+ finishWithProof(proof)
+ } catch (e: ClientRequestException) {
+ val detail = if (e.response.status.value == 400) {
+ runCatching { e.response.body().detail }
+ .getOrNull()
+ ?.ifBlank { null }
+ } else {
+ null
+ }
+ finishWithError(detail ?: failedMessage)
+ } catch (_: Exception) {
+ finishWithError(failedMessage)
+ } finally {
+ busy = false
+ }
+ }
+ },
+ onError = { finishWithError(it.ifBlank { failedMessage }) },
+ onCancel = { cancel() },
+ )
+
+ // Visible when back exits to our Yandex ID step (WebView has no in-page history).
+ AnimatedVisibility(
+ visible = !webViewCanGoBack && !busy,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .safeDrawingPadding()
+ .padding(start = 12.dp, top = 12.dp),
+ ) {
+ FilledIconButton(
+ onClick = { cancel() },
+ colors = IconButtonDefaults.filledIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.92f),
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ ),
+ ) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = backLabel)
+ }
+ }
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.kt
new file mode 100644
index 0000000..353197e
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.kt
@@ -0,0 +1,28 @@
+package ru.fromchat.ui.auth.yandex
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Platform WebView that loads [authorizeUrl] and reports the OAuth redirect.
+ * Intercepts `fromchat://oauth/yandex` and returns the authorization code.
+ *
+ * @param languageTag BCP-47 tag for Accept-Language / WebView locale.
+ * @param darkTheme Pins WebView `prefers-color-scheme` via configuration night mode.
+ * @param fallbackColor Shown until the page background can be sampled.
+ * @param onPageBackgroundColor Reported whenever a non-transparent page background is detected.
+ * @param onHistoryBackAvailabilityChanged `true` when the WebView can go back in its own history.
+ */
+@Composable
+expect fun YandexOAuthWebView(
+ authorizeUrl: String,
+ languageTag: String,
+ darkTheme: Boolean,
+ fallbackColor: Color,
+ clearCookies: Boolean = false,
+ onPageBackgroundColor: (Color) -> Unit = {},
+ onHistoryBackAvailabilityChanged: (Boolean) -> Unit = {},
+ onCode: (String) -> Unit,
+ onError: (String) -> Unit,
+ onCancel: () -> Unit,
+)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt
index 9fb2733..574b1d1 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsRoutes.kt
@@ -12,6 +12,10 @@ object SettingsRoutes {
const val SecurityPasswordFlow = "settings/security/password"
const val Account = "settings/account"
const val AccountDeleteFlow = "settings/account/delete"
+ /** Confirm → OAuth WebView → Done (Done pops confirm+OAuth). */
+ const val AccountYandexFlow = "settings/account/yandex"
+ const val AccountYandexOAuth = "settings/account/yandex/oauth"
+ const val AccountYandexDone = "settings/account/yandex/done"
const val ServerConfig = "serverConfig"
const val About = "about"
const val Logs = "settings/logs"
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt
index 38d3fbc..423c9cd 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/AccountScreen.kt
@@ -25,6 +25,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -37,11 +38,15 @@ import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.compose.resources.vectorResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.back
import ru.fromchat.cancel
+import ru.fromchat.ic_yandex
import ru.fromchat.logout
+import ru.fromchat.settings_account_change_yandex
+import ru.fromchat.settings_account_change_yandex_d
import ru.fromchat.settings_account_delete
import ru.fromchat.settings_account_delete_d
import ru.fromchat.settings_account_logout_confirm_body
@@ -57,11 +62,18 @@ fun AccountScreen(
onBack: () -> Unit,
onLogout: () -> Unit,
onChangePassword: () -> Unit,
+ onChangeYandexId: () -> Unit,
onDeleteAccount: () -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val scope = rememberCoroutineScope()
var showLogoutConfirm by remember { mutableStateOf(false) }
+ var yandexAvailable by remember { mutableStateOf(false) }
+ val yandexIcon = vectorResource(Res.drawable.ic_yandex)
+
+ LaunchedEffect(Unit) {
+ yandexAvailable = runCatching { ApiClient.getAccountYandex() }.isSuccess
+ }
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -103,6 +115,24 @@ fun AccountScreen(
}
)
+ if (yandexAvailable) {
+ ListItem(
+ headline = stringResource(Res.string.settings_account_change_yandex),
+ supportingText = stringResource(Res.string.settings_account_change_yandex_d),
+ onClick = onChangeYandexId,
+ leadingContent = { Icon(yandexIcon, null) },
+ divider = true,
+ trailingContent = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ )
+ }
+
ListItem(
headline = stringResource(Res.string.settings_account_delete),
supportingText = stringResource(Res.string.settings_account_delete_d),
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changeyandex/ChangeYandexConfirmScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changeyandex/ChangeYandexConfirmScreen.kt
new file mode 100644
index 0000000..88bab55
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changeyandex/ChangeYandexConfirmScreen.kt
@@ -0,0 +1,155 @@
+package ru.fromchat.ui.main.settings.account.changeyandex
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialShapes
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.intl.Locale
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.jetbrains.compose.resources.vectorResource
+import ru.fromchat.Res
+import ru.fromchat.api.ApiClient
+import ru.fromchat.api.schema.user.auth.YandexOAuthParams
+import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
+import ru.fromchat.auth.yandex.buildYandexAuthorizeUrl
+import ru.fromchat.auth.yandex.generatePkcePair
+import ru.fromchat.auth.yandex.resolveYandexClientId
+import ru.fromchat.auth_yandex_client_mismatch
+import ru.fromchat.config.Settings
+import ru.fromchat.error_unexpected
+import ru.fromchat.ic_yandex
+import ru.fromchat.settings_next
+import ru.fromchat.settings_yandex_step_confirm_body
+import ru.fromchat.settings_yandex_step_confirm_cta
+import ru.fromchat.settings_yandex_step_confirm_title
+import ru.fromchat.ui.LocalNavController
+import ru.fromchat.ui.components.ActionButton
+import ru.fromchat.ui.components.ExpressiveHeroSpec
+import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
+import ru.fromchat.ui.components.ExpressiveStepPage
+import ru.fromchat.ui.components.ExpressiveStepPageHeader
+import ru.fromchat.ui.components.Text
+import ru.fromchat.ui.components.rememberExpressiveStepFlow
+import ru.fromchat.ui.components.showReplacingSnackbar
+import ru.fromchat.ui.isAppInDarkTheme
+import ru.fromchat.ui.main.settings.SettingsRoutes
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun ChangeYandexConfirmScreen(onBack: () -> Unit) {
+ val navController = LocalNavController.current
+ val scope = rememberCoroutineScope()
+ val snackbarHostState = remember { SnackbarHostState() }
+ val flowState = rememberExpressiveStepFlow(1)
+ var yandex by remember { mutableStateOf(null) }
+ var loadingParams by remember { mutableStateOf(true) }
+ var busy by remember { mutableStateOf(false) }
+ val darkTheme = isAppInDarkTheme()
+ val languageTag = Locale.current.toLanguageTag()
+ val yandexIcon = vectorResource(Res.drawable.ic_yandex)
+
+ val title = stringResource(Res.string.settings_yandex_step_confirm_title)
+ val body = stringResource(Res.string.settings_yandex_step_confirm_body)
+ val cta = stringResource(Res.string.settings_yandex_step_confirm_cta)
+ val next = stringResource(Res.string.settings_next)
+ val clientMismatch = stringResource(Res.string.auth_yandex_client_mismatch)
+ val unexpected = stringResource(Res.string.error_unexpected)
+
+ fun showSnack(text: String) {
+ scope.launch {
+ snackbarHostState.showReplacingSnackbar(
+ message = text,
+ withDismissAction = false,
+ duration = SnackbarDuration.Short,
+ )
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ ChangeYandexDraft.clear()
+ loadingParams = true
+ try {
+ yandex = ApiClient.getAccountYandex().yandex
+ } catch (e: Exception) {
+ showSnack(e.message ?: unexpected)
+ } finally {
+ loadingParams = false
+ }
+ }
+
+ val colorScheme = MaterialTheme.colorScheme
+ ExpressiveStepFlowScaffold(
+ flowState = flowState,
+ pages = listOf(
+ ExpressiveStepPage(
+ hero = ExpressiveHeroSpec(
+ icon = yandexIcon,
+ polygon = MaterialShapes.Cookie9Sided.normalized(),
+ containerColor = colorScheme.primaryContainer,
+ contentColor = colorScheme.onPrimaryContainer,
+ ),
+ content = {
+ ExpressiveStepPageHeader(title = title, body = body)
+ },
+ button = {
+ ActionButton(
+ onClick = {
+ if (busy || loadingParams) return@ActionButton
+ val params = yandex
+ if (params == null) {
+ showSnack(unexpected)
+ return@ActionButton
+ }
+ busy = true
+ scope.launch {
+ try {
+ val serverIp = runCatching { Settings.serverConfig.serverIp }.getOrElse {
+ showSnack(it.message ?: unexpected)
+ return@launch
+ }
+ val clientId = resolveYandexClientId(params.client_id, serverIp)
+ if (clientId == null) {
+ showSnack(clientMismatch)
+ return@launch
+ }
+ val pkce = generatePkcePair()
+ ChangeYandexDraft.authorizeUrl = buildYandexAuthorizeUrl(
+ authorizeUrl = params.authorize_url,
+ clientId = clientId,
+ redirectUri = params.redirect_uri.ifBlank { YANDEX_OAUTH_REDIRECT_URI },
+ scope = params.scope,
+ codeChallenge = pkce.codeChallenge,
+ languageTag = languageTag,
+ darkTheme = darkTheme,
+ )
+ ChangeYandexDraft.codeVerifier = pkce.codeVerifier
+ navController.navigate(SettingsRoutes.AccountYandexOAuth)
+ } finally {
+ busy = false
+ }
+ }
+ },
+ enabled = !busy && !loadingParams && yandex != null,
+ loading = busy || loadingParams,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(if (busy || loadingParams) next else cta)
+ }
+ },
+ ),
+ ),
+ snackbarHostState = snackbarHostState,
+ onBackAtFirstPage = onBack,
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changeyandex/ChangeYandexDoneScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changeyandex/ChangeYandexDoneScreen.kt
new file mode 100644
index 0000000..566df56
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changeyandex/ChangeYandexDoneScreen.kt
@@ -0,0 +1,62 @@
+package ru.fromchat.ui.main.settings.account.changeyandex
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialShapes
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import org.jetbrains.compose.resources.stringResource
+import ru.fromchat.Res
+import ru.fromchat.settings_done
+import ru.fromchat.settings_yandex_step_done_body
+import ru.fromchat.settings_yandex_step_done_title
+import ru.fromchat.ui.components.ActionButton
+import ru.fromchat.ui.components.ExpressiveHeroSpec
+import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
+import ru.fromchat.ui.components.ExpressiveStepPage
+import ru.fromchat.ui.components.ExpressiveStepPageHeader
+import ru.fromchat.ui.components.Text
+import ru.fromchat.ui.components.rememberExpressiveStepFlow
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun ChangeYandexDoneScreen(onDone: () -> Unit) {
+ val flowState = rememberExpressiveStepFlow(1)
+ val snackbarHostState = remember { SnackbarHostState() }
+ val title = stringResource(Res.string.settings_yandex_step_done_title)
+ val body = stringResource(Res.string.settings_yandex_step_done_body)
+ val done = stringResource(Res.string.settings_done)
+ val colorScheme = MaterialTheme.colorScheme
+
+ ExpressiveStepFlowScaffold(
+ flowState = flowState,
+ pages = listOf(
+ ExpressiveStepPage(
+ hero = ExpressiveHeroSpec(
+ icon = Icons.Filled.CheckCircle,
+ polygon = MaterialShapes.Cookie9Sided.normalized(),
+ containerColor = colorScheme.tertiaryContainer,
+ contentColor = colorScheme.onTertiaryContainer,
+ ),
+ content = {
+ ExpressiveStepPageHeader(title = title, body = body)
+ },
+ button = {
+ ActionButton(
+ onClick = onDone,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(done)
+ }
+ },
+ ),
+ ),
+ snackbarHostState = snackbarHostState,
+ onBackAtFirstPage = onDone,
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changeyandex/ChangeYandexDraft.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changeyandex/ChangeYandexDraft.kt
new file mode 100644
index 0000000..75fc1d2
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changeyandex/ChangeYandexDraft.kt
@@ -0,0 +1,15 @@
+package ru.fromchat.ui.main.settings.account.changeyandex
+
+/**
+ * Stages PKCE + authorize URL while the change-Yandex OAuth WebView is open
+ * (settings composition can leave the confirm screen).
+ */
+internal object ChangeYandexDraft {
+ var authorizeUrl: String? = null
+ var codeVerifier: String? = null
+
+ fun clear() {
+ authorizeUrl = null
+ codeVerifier = null
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changeyandex/ChangeYandexOAuthScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changeyandex/ChangeYandexOAuthScreen.kt
new file mode 100644
index 0000000..07476af
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changeyandex/ChangeYandexOAuthScreen.kt
@@ -0,0 +1,125 @@
+package ru.fromchat.ui.main.settings.account.changeyandex
+
+import androidx.compose.animation.AnimatedVisibility
+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.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.intl.Locale
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import ru.fromchat.Res
+import ru.fromchat.api.ApiClient
+import ru.fromchat.back
+import ru.fromchat.ui.LocalNavController
+import ru.fromchat.ui.auth.yandex.YandexOAuthWebView
+import ru.fromchat.ui.isAppInDarkTheme
+import ru.fromchat.ui.main.settings.SettingsRoutes
+
+@Composable
+fun ChangeYandexOAuthScreen(onBack: () -> Unit) {
+ val navController = LocalNavController.current
+ val scope = rememberCoroutineScope()
+ val authorizeUrl = remember { ChangeYandexDraft.authorizeUrl }
+ val codeVerifier = remember { ChangeYandexDraft.codeVerifier }
+ val fallbackColor = MaterialTheme.colorScheme.background
+ var chromeColor by remember { mutableStateOf(fallbackColor) }
+ var busy by remember { mutableStateOf(false) }
+ var webViewCanGoBack by remember { mutableStateOf(false) }
+ val backLabel = stringResource(Res.string.back)
+
+ LaunchedEffect(authorizeUrl, codeVerifier) {
+ if (authorizeUrl.isNullOrBlank() || codeVerifier.isNullOrBlank()) {
+ onBack()
+ }
+ }
+
+ val url = authorizeUrl ?: return
+ val verifier = codeVerifier ?: return
+
+ fun finishSuccess() {
+ ChangeYandexDraft.clear()
+ navController.navigate(SettingsRoutes.AccountYandexDone) {
+ popUpTo(SettingsRoutes.AccountYandexFlow) { inclusive = true }
+ }
+ }
+
+ fun cancel() {
+ if (busy) return
+ ChangeYandexDraft.clear()
+ onBack()
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(chromeColor),
+ ) {
+ YandexOAuthWebView(
+ authorizeUrl = url,
+ languageTag = Locale.current.toLanguageTag(),
+ darkTheme = isAppInDarkTheme(),
+ fallbackColor = fallbackColor,
+ clearCookies = true,
+ onPageBackgroundColor = { chromeColor = it },
+ onHistoryBackAvailabilityChanged = { webViewCanGoBack = it },
+ onCode = { code ->
+ if (busy) return@YandexOAuthWebView
+ scope.launch {
+ busy = true
+ try {
+ val proof = ApiClient.authYandexExchange(code, verifier).registration_proof
+ ApiClient.changeAccountYandex(proof)
+ finishSuccess()
+ } catch (_: Exception) {
+ cancel()
+ } finally {
+ busy = false
+ }
+ }
+ },
+ onError = { cancel() },
+ onCancel = { cancel() },
+ )
+
+ // Same as register: show when back exits the flow (WebView has no in-page history).
+ AnimatedVisibility(
+ visible = !webViewCanGoBack && !busy,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .safeDrawingPadding()
+ .padding(start = 12.dp, top = 12.dp),
+ ) {
+ FilledIconButton(
+ onClick = { cancel() },
+ colors = IconButtonDefaults.filledIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.92f),
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ ),
+ ) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = backLabel)
+ }
+ }
+ }
+}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.ios.kt
new file mode 100644
index 0000000..d0b9f96
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.ios.kt
@@ -0,0 +1,23 @@
+package ru.fromchat.ui.auth.yandex
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.graphics.Color
+
+@Composable
+actual fun YandexOAuthWebView(
+ authorizeUrl: String,
+ languageTag: String,
+ darkTheme: Boolean,
+ fallbackColor: Color,
+ clearCookies: Boolean,
+ onPageBackgroundColor: (Color) -> Unit,
+ onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
+ onCode: (String) -> Unit,
+ onError: (String) -> Unit,
+ onCancel: () -> Unit,
+) {
+ LaunchedEffect(authorizeUrl) {
+ onError("Yandex sign-in is not available on this platform yet.")
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 66de5e0..442bf60 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -46,6 +46,7 @@ livekitAndroid = "2.25.2"
livekitAndroidComposeComponents = "2.3.0"
markdownRendererM3 = "0.41.0"
bouncycastle = "1.79"
+webkit = "1.14.0"
[libraries]
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
@@ -110,6 +111,7 @@ krypto = { module = "com.soywiz.korlibs.krypto:krypto", version.ref = "krypto" }
cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", version.ref = "cryptography-kotlin" }
cryptography-provider-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "cryptography-kotlin" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidxWork" }
+androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }