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" }