diff --git a/app/android/src/main/AndroidManifest.xml b/app/android/src/main/AndroidManifest.xml
index a9c2985..a5dd8fc 100644
--- a/app/android/src/main/AndroidManifest.xml
+++ b/app/android/src/main/AndroidManifest.xml
@@ -54,6 +54,15 @@
android:host="oauth"
android:pathPrefix="/yandex" />
+
+
+
+
+
+
Boolean,
clearCookies: Boolean,
+ themeCookieHosts: List,
onPageBackgroundColor: (Color) -> Unit,
onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
- onCode: (String) -> Unit,
+ onRedirectUrl: (String) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit,
) {
@@ -158,8 +159,10 @@ actual fun YandexOAuthWebView(
val scheme = if (darkTheme) "dark" else "light"
val onHistoryBackAvailabilityChangedState = rememberUpdatedState(onHistoryBackAvailabilityChanged)
val onPageBackgroundColorState = rememberUpdatedState(onPageBackgroundColor)
- val onCodeState = rememberUpdatedState(onCode)
+ val onRedirectUrlState = rememberUpdatedState(onRedirectUrl)
val onErrorState = rememberUpdatedState(onError)
+ val isAuthNavigationState = rememberUpdatedState(isAuthNavigation)
+ val redirectUriPrefixState = rememberUpdatedState(redirectUriPrefix)
val onCancelState = rememberUpdatedState(onCancel)
val lifecycleOwner = LocalLifecycleOwner.current
val instanceId = remember { Integer.toHexString(System.identityHashCode(Any())) }
@@ -193,7 +196,7 @@ actual fun YandexOAuthWebView(
}
}
- ApplyYandexWebViewSystemBars(
+ ApplyOAuthWebViewSystemBars(
chromeColor = chromeColor,
darkTheme = darkTheme,
restoreSurfaceColor = fallbackColor,
@@ -269,7 +272,7 @@ actual fun YandexOAuthWebView(
onCancel = { },
)
- val client = remember(authorizeUrl, lang, darkTheme) {
+ val client = remember(authorizeUrl, lang, darkTheme, redirectUriPrefix) {
Logger.i(
LOG_TAG,
"WebViewClient create id=$instanceId darkTheme=$darkTheme lang=$lang " +
@@ -278,17 +281,16 @@ actual fun YandexOAuthWebView(
object : WebViewClient() {
private fun handleSpecialUrl(view: WebView?, url: String?): Boolean {
if (url.isNullOrBlank()) return false
- if (url.startsWith("fromchat://", ignoreCase = true)) {
+ val redirectPrefix = redirectUriPrefixState.value
+ // Intercept trusted HTTPS callback (and fromchat:// deep links) before any page paint.
+ if (url.startsWith(redirectPrefix, ignoreCase = true) ||
+ 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("")
- }
+ onRedirectUrlState.value(url)
return true
}
- if (!isYandexAuthNavigation(url)) {
+ if (!isAuthNavigationState.value(url)) {
Logger.d(LOG_TAG, "external nav url=${shortUrl(url)} id=$instanceId")
view?.context?.let { ctx ->
runCatching {
@@ -327,7 +329,11 @@ actual fun YandexOAuthWebView(
"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)) {
+ if (url != null && (
+ url.startsWith(redirectUriPrefixState.value, ignoreCase = true) ||
+ url.startsWith("fromchat://", ignoreCase = true)
+ )
+ ) {
handleSpecialUrl(view, url)
}
pageLoading = true
@@ -374,16 +380,16 @@ actual fun YandexOAuthWebView(
"stack=${Throwable().stackTraceToString().lineSequence().drop(1).take(10).joinToString(" ← ")}",
)
if (clearCookies) {
- clearYandexWebViewCookies()
+ clearOAuthWebViewCookies()
}
- seedYandexThemeCookies(darkTheme)
+ seedOAuthThemeCookies(darkTheme, themeCookieHosts)
WebView(activity).apply {
setBackgroundColor(fallbackColor.toArgb())
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.javaScriptCanOpenWindowsAutomatically = true
settings.setSupportMultipleWindows(true)
- applyYandexDarkSettingsIfNeeded(this, darkTheme)
+ applyOAuthDarkSettingsIfNeeded(this, darkTheme)
webViewClient = client
webChromeClient = object : WebChromeClient() {
override fun onCreateWindow(
@@ -394,14 +400,14 @@ actual fun YandexOAuthWebView(
): Boolean {
val transport = resultMsg?.obj as? WebView.WebViewTransport ?: return false
val temp = WebView(activity).apply {
- applyYandexDarkSettingsIfNeeded(this, darkTheme)
+ applyOAuthDarkSettingsIfNeeded(this, darkTheme)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
v: WebView?,
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString() ?: return false
- if (!isYandexAuthNavigation(url)) {
+ if (!isAuthNavigationState.value(url)) {
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url)),
@@ -451,7 +457,7 @@ actual fun YandexOAuthWebView(
update = { wv ->
val clientChanged = wv.webViewClient !== client
val darkBefore = wv.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
- applyYandexDarkSettingsIfNeeded(wv, darkTheme)
+ applyOAuthDarkSettingsIfNeeded(wv, darkTheme)
if (clientChanged) {
Logger.i(
LOG_TAG,
@@ -500,7 +506,7 @@ actual fun YandexOAuthWebView(
}
@Composable
-private fun ApplyYandexWebViewSystemBars(
+private fun ApplyOAuthWebViewSystemBars(
chromeColor: Color,
darkTheme: Boolean,
restoreSurfaceColor: Color,
@@ -557,26 +563,19 @@ private fun Context.findActivity(): Activity? {
return null
}
-private fun clearYandexWebViewCookies() {
+private fun clearOAuthWebViewCookies() {
val cookieManager = CookieManager.getInstance()
cookieManager.setAcceptCookie(true)
cookieManager.removeAllCookies(null)
cookieManager.flush()
- Logger.i(LOG_TAG, "cleared all WebView cookies for Yandex re-auth")
+ Logger.i(LOG_TAG, "cleared all WebView cookies for OAuth re-auth")
}
-private fun seedYandexThemeCookies(darkTheme: Boolean) {
+private fun seedOAuthThemeCookies(darkTheme: Boolean, hosts: List) {
+ if (hosts.isEmpty()) return
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=/")
@@ -590,7 +589,7 @@ private fun seedYandexThemeCookies(darkTheme: Boolean) {
* 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) {
+private fun applyOAuthDarkSettingsIfNeeded(webView: WebView, darkTheme: Boolean) {
val previous = webView.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
if (previous == darkTheme) return
Logger.w(
@@ -671,36 +670,3 @@ private suspend fun sampleTopRowColor(webView: WebView): Color? {
}
}
}
-
-/**
- * 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_vk.xml b/app/shared/src/commonMain/composeResources/drawable/ic_vk.xml
new file mode 100644
index 0000000..d6ebcbf
--- /dev/null
+++ b/app/shared/src/commonMain/composeResources/drawable/ic_vk.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 5521743..f8519b5 100644
--- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -55,9 +55,14 @@
Войдите через Яндекс ID
Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. От Яндекса мы получаем только email — и мы его не сохраняем: вход нужен лишь для защиты.
Продолжить через Яндекс ID
+ Подтвердите аккаунт
+ Выберите Яндекс ID или VK ID. Мы используем это только против ботов и для соблюдения законов — email и профиль этих сервисов не сохраняем.
+ Продолжить через VK ID
Яндекс ID
Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.
Вход через Яндекс ID отменён или не удался.
+ Сервер вернул неожиданный идентификатор приложения VK. Обновите приложение или обратитесь в поддержку.
+ Вход через VK ID отменён или не удался.
Чаты
Контакты
Профиль
@@ -433,6 +438,13 @@
Продолжить
Яндекс ID обновлён
Аккаунт теперь привязан к Яндекс ID, с которым вы только что вошли.
+ Сменить VK ID
+ Привязать другой аккаунт VK
+ Сменить VK ID?
+ Это не удаляет ваш аккаунт FromChat. Предыдущий VK ID будет освобождён, а вместо него привяжется тот, с которым вы войдёте.
+ Продолжить
+ VK ID обновлён
+ Аккаунт теперь привязан к VK ID, с которым вы только что вошли.
Готово
Удалить аккаунт?
Это нельзя отменить.
diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml
index fca2c67..1e3beeb 100644
--- a/app/shared/src/commonMain/composeResources/values/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values/strings.xml
@@ -62,9 +62,14 @@
Sign in with Yandex ID
This helps us fight malicious bots and meet Russian legal requirements. From Yandex we only get your email — and we don’t store it; sign-in is only used for security reasons.
Continue with Yandex ID
+ Verify your account
+ Choose Yandex ID or VK ID. We only use this to fight bots and meet legal requirements — we don’t store your email or profile from these providers.
+ Continue with VK ID
Yandex ID
This server returned an unexpected Yandex app id. Update the app or contact support.
Yandex sign-in was cancelled or failed.
+ This server returned an unexpected VK app id. Update the app or contact support.
+ VK sign-in was cancelled or failed.
Chats
Contacts
@@ -461,6 +466,13 @@
Continue
Yandex ID updated
Your account is now linked to the Yandex ID you just signed in with.
+ Change VK ID
+ Link a different VK account
+ Change VK ID?
+ This does not delete your FromChat account. Your previous VK ID will be freed, and the new one you sign in with will be linked instead.
+ Continue
+ VK ID updated
+ Your account is now linked to the VK ID you just signed in with.
Done
Delete account?
This cannot be undone.
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
index 1ee8491..e754982 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
@@ -107,13 +107,19 @@ 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.AccountVkResponse
import ru.fromchat.api.schema.user.auth.AccountYandexResponse
+import ru.fromchat.api.schema.user.auth.ChangeVkRequest
+import ru.fromchat.api.schema.user.auth.ChangeVkResponse
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.LoginResponse
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
+import ru.fromchat.api.schema.user.auth.VkExchangeRequest
+import ru.fromchat.api.schema.user.auth.VkExchangeResponse
+import ru.fromchat.api.schema.user.auth.VkOAuthParams
import ru.fromchat.api.schema.user.auth.YandexExchangeRequest
import ru.fromchat.api.schema.user.auth.YandexExchangeResponse
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
@@ -493,8 +499,9 @@ object ApiClient {
sealed interface AuthPasswordStepOutcome {
data class LoggedIn(val response: LoginResponse) : AuthPasswordStepOutcome
data class NeedsRegister(
- val yandexRequired: Boolean,
+ val verificationRequired: Boolean,
val yandex: YandexOAuthParams?,
+ val vk: VkOAuthParams?,
) : AuthPasswordStepOutcome
}
@@ -513,8 +520,9 @@ object ApiClient {
val status = raw["status"]?.jsonPrimitive?.contentOrNull
return when (status) {
"needs_register" -> AuthPasswordStepOutcome.NeedsRegister(
- yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true,
+ verificationRequired = raw["verification_required"]?.jsonPrimitive?.booleanOrNull == true,
yandex = raw["yandex"]?.let { json.decodeFromJsonElement(YandexOAuthParams.serializer(), it) },
+ vk = raw["vk"]?.let { json.decodeFromJsonElement(VkOAuthParams.serializer(), it) },
)
else -> AuthPasswordStepOutcome.LoggedIn(json.decodeFromJsonElement(LoginResponse.serializer(), raw))
}
@@ -528,6 +536,26 @@ object ApiClient {
}
.body()
+ suspend fun authVkExchange(
+ code: String,
+ codeVerifier: String,
+ deviceId: String,
+ state: String,
+ ): VkExchangeResponse =
+ httpProbe
+ .post("${ServerConfig.apiBaseUrl}/auth/vk/exchange") {
+ contentType(ContentType.Application.Json)
+ setBody(
+ VkExchangeRequest(
+ code = code,
+ code_verifier = codeVerifier,
+ device_id = deviceId,
+ state = state,
+ ),
+ )
+ }
+ .body()
+
suspend fun authRegisterConfirm(request: RegisterConfirmRequest): LoginResponse =
httpProbe
.post("${ServerConfig.apiBaseUrl}/auth/steps/register/confirm") {
@@ -1542,6 +1570,19 @@ object ApiClient {
}
.body()
+ suspend fun getAccountVk(): AccountVkResponse =
+ http
+ .get("${ServerConfig.apiBaseUrl}/account/vk")
+ .body()
+
+ suspend fun changeAccountVk(registrationProof: String): ChangeVkResponse =
+ http
+ .post("${ServerConfig.apiBaseUrl}/account/vk") {
+ contentType(ContentType.Application.Json)
+ setBody(ChangeVkRequest(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
index 32eec47..df89122 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/AuthSteps.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/auth/AuthSteps.kt
@@ -27,11 +27,20 @@ data class YandexOAuthParams(
val scope: String,
)
+@Serializable
+data class VkOAuthParams(
+ 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 verification_required: Boolean = false,
val yandex: YandexOAuthParams? = null,
+ val vk: VkOAuthParams? = null,
)
@Serializable
@@ -45,6 +54,19 @@ data class YandexExchangeResponse(
val registration_proof: String,
)
+@Serializable
+data class VkExchangeRequest(
+ val code: String,
+ val code_verifier: String,
+ val device_id: String,
+ val state: String,
+)
+
+@Serializable
+data class VkExchangeResponse(
+ val registration_proof: String,
+)
+
@Serializable
data class AccountYandexResponse(
val linked: Boolean = false,
@@ -62,6 +84,23 @@ data class ChangeYandexResponse(
val unchanged: Boolean = false,
)
+@Serializable
+data class AccountVkResponse(
+ val linked: Boolean = false,
+ val vk: VkOAuthParams? = null,
+)
+
+@Serializable
+data class ChangeVkRequest(
+ val registration_proof: String,
+)
+
+@Serializable
+data class ChangeVkResponse(
+ val status: String? = null,
+ val unchanged: Boolean = false,
+)
+
@Serializable
data class RegisterConfirmRequest(
val username: String,
@@ -70,4 +109,5 @@ data class RegisterConfirmRequest(
val confirm_password: String,
val bio: String? = null,
val registration_proof: String? = null,
+ val vk_registration_proof: String? = null,
)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/auth/vk/VkAuthNavigation.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/auth/vk/VkAuthNavigation.kt
new file mode 100644
index 0000000..4a9a555
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/auth/vk/VkAuthNavigation.kt
@@ -0,0 +1,36 @@
+package ru.fromchat.auth.vk
+
+/**
+ * Keep VK ID / OAuth / captcha flows in the WebView; open everything else externally.
+ */
+internal fun isVkAuthNavigation(url: String): Boolean {
+ if (url.startsWith("fromchat://", ignoreCase = true)) return true
+ val withoutScheme = url.substringAfter("://", missingDelimiterValue = "")
+ val host = withoutScheme.substringBefore('/').substringBefore('?').substringBefore('#').lowercase()
+
+ return host == "id.vk.ru" ||
+ host == "id.vk.com" ||
+ host.endsWith(".id.vk.ru") ||
+ host.endsWith(".id.vk.com") ||
+ host == "login.vk.ru" ||
+ host == "login.vk.com" ||
+ host == "oauth.vk.com" ||
+ host == "oauth.vk.ru" ||
+ host == "m.vk.ru" ||
+ host == "m.vk.com" ||
+ host == "vk.ru" ||
+ host == "vk.com" ||
+ host == "www.vk.ru" ||
+ host == "www.vk.com" ||
+ host == "api.fromchat.ru" ||
+ (host.contains("captcha") && host.contains("vk"))
+}
+
+internal val VK_OAUTH_THEME_COOKIE_HOSTS = listOf(
+ "https://id.vk.ru",
+ "https://id.vk.com",
+ "https://vk.ru",
+ "https://vk.com",
+ "https://login.vk.ru",
+ "https://oauth.vk.com",
+)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/auth/vk/VkOAuth.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/auth/vk/VkOAuth.kt
new file mode 100644
index 0000000..3277f3f
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/auth/vk/VkOAuth.kt
@@ -0,0 +1,150 @@
+package ru.fromchat.auth.vk
+
+import kotlin.random.Random
+import ru.fromchat.auth.yandex.PkcePair
+import ru.fromchat.auth.yandex.generatePkcePair
+import ru.fromchat.auth.yandex.isOfficialApiHost
+
+/**
+ * Prod VK 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_VK_OAUTH_CLIENT_ID = ""
+
+internal const val VK_OAUTH_REDIRECT_URI = "https://api.fromchat.ru/oauth/vk"
+internal const val VK_OAUTH_DEEP_LINK = "fromchat://oauth/vk"
+
+private const val OAUTH_STATE_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
+
+/**
+ * Returns the client_id to use, or null if the official host sent a mismatched id.
+ */
+internal fun resolveVkClientId(serverClientId: String, serverIp: String): String? {
+ val trimmed = serverClientId.trim()
+ if (trimmed.isEmpty()) return null
+ if (!isOfficialApiHost(serverIp)) return trimmed
+ val pinned = OFFICIAL_VK_OAUTH_CLIENT_ID.trim()
+ if (pinned.isEmpty()) return trimmed
+ return if (pinned == trimmed) trimmed else null
+}
+
+internal fun generateOAuthState(length: Int = 43): String {
+ require(length >= 32)
+ val bytes = ByteArray(length).also { Random.Default.nextBytes(it) }
+ return buildString(length) {
+ for (b in bytes) {
+ append(OAUTH_STATE_ALPHABET[(b.toInt() and 0x7f) % OAUTH_STATE_ALPHABET.length])
+ }
+ }
+}
+
+internal fun buildVkAuthorizeUrl(
+ authorizeUrl: String,
+ clientId: String,
+ redirectUri: String,
+ scope: String,
+ codeChallenge: String,
+ state: String,
+ languageTag: String = "en",
+ darkTheme: Boolean = false,
+): String {
+ val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
+ val langId = if (lang == "ru") "0" else "3"
+ val scheme = if (darkTheme) "dark" else "light"
+ val base = authorizeUrl.trim().trimEnd('?')
+ 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("state" to state)
+ add("lang_id" to langId)
+ add("scheme" to scheme)
+ }.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())
+ }
+ }
+ }
+ }
+}
+
+data class VkOAuthRedirect(
+ val code: String,
+ val deviceId: String,
+ val state: String,
+)
+
+internal fun extractVkOAuthRedirect(redirectUrl: String, redirectUri: String = VK_OAUTH_REDIRECT_URI): VkOAuthRedirect? {
+ val uri = redirectUrl.trim()
+ val expectedHttps = redirectUri.trim().ifBlank { VK_OAUTH_REDIRECT_URI }
+ val matchesPrefix = uri.startsWith(expectedHttps, ignoreCase = true) ||
+ uri.startsWith(VK_OAUTH_DEEP_LINK, ignoreCase = true) ||
+ uri.contains("/oauth/vk?", ignoreCase = true) ||
+ uri.substringBefore('?', missingDelimiterValue = uri).endsWith("/oauth/vk", ignoreCase = true)
+ if (!matchesPrefix) return null
+ val query = uri.substringAfter('?', missingDelimiterValue = "")
+ if (query.isEmpty()) return null
+ var code: String? = null
+ var deviceId: String? = null
+ var state: String? = null
+ for (part in query.split('&')) {
+ val key = part.substringBefore('=')
+ val raw = part.substringAfter('=', missingDelimiterValue = "")
+ if (raw.isEmpty()) continue
+ val decoded = decodeUrl(raw)
+ when (key) {
+ "code" -> code = decoded
+ "device_id" -> deviceId = decoded
+ "state" -> state = decoded
+ }
+ }
+ val c = code ?: return null
+ val d = deviceId ?: return null
+ val s = state ?: return null
+ return VkOAuthRedirect(code = c, deviceId = d, state = s)
+}
+
+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()
+}
+
+// Re-export PKCE helpers used by VK flows (same module as Yandex).
+internal fun generateVkPkcePair(): PkcePair = generatePkcePair()
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/auth/yandex/YandexAuthNavigation.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/auth/yandex/YandexAuthNavigation.kt
new file mode 100644
index 0000000..d699b88
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/auth/yandex/YandexAuthNavigation.kt
@@ -0,0 +1,44 @@
+package ru.fromchat.auth.yandex
+
+/**
+ * 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 withoutScheme = url.substringAfter("://", missingDelimiterValue = "")
+ val hostAndPath = withoutScheme.substringBefore('#').substringBefore('?')
+ val host = hostAndPath.substringBefore('/').lowercase()
+ val path = hostAndPath.substringAfter('/', missingDelimiterValue = "").lowercase().let { "/$it" }
+
+ 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"))
+}
+
+internal val YANDEX_OAUTH_THEME_COOKIE_HOSTS = listOf(
+ "https://yandex.ru",
+ "https://yandex.com",
+ "https://passport.yandex.ru",
+ "https://passport.yandex.com",
+ "https://oauth.yandex.ru",
+ "https://oauth.yandex.com",
+)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
index 0079b3f..50a8379 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.vk.VkOAuthNav
+import ru.fromchat.ui.auth.vk.VkOAuthScreen
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
import ru.fromchat.ui.calls.CallOverlay
@@ -103,6 +105,9 @@ 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.changevk.ChangeVkConfirmScreen
+import ru.fromchat.ui.main.settings.account.changevk.ChangeVkDoneScreen
+import ru.fromchat.ui.main.settings.account.changevk.ChangeVkOAuthScreen
import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
import ru.fromchat.ui.main.settings.server.ServerConfigScreen
import ru.fromchat.ui.profile.EditProfileFocusField
@@ -466,6 +471,10 @@ fun App(
YandexOAuthScreen()
}
+ composable(VkOAuthNav.ROUTE) {
+ VkOAuthScreen()
+ }
+
composable("chat") {
MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
@@ -699,6 +708,26 @@ fun App(
)
}
+ settingsComposable(SettingsRoutes.AccountVkFlow) {
+ ChangeVkConfirmScreen(
+ onBack = { navController.navigateUp() },
+ )
+ }
+
+ settingsComposable(SettingsRoutes.AccountVkOAuth) {
+ ChangeVkOAuthScreen(
+ onBack = { navController.navigateUp() },
+ )
+ }
+
+ settingsComposable(SettingsRoutes.AccountVkDone) {
+ ChangeVkDoneScreen(
+ onDone = {
+ navController.popBackStack(SettingsRoutes.Account, inclusive = false)
+ },
+ )
+ }
+
settingsComposable(SettingsRoutes.Account) {
AccountScreen(
onBack = { navController.navigateUp() },
@@ -709,6 +738,7 @@ fun App(
},
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) },
onChangeYandexId = { navController.navigate(SettingsRoutes.AccountYandexFlow) },
+ onChangeVkId = { navController.navigate(SettingsRoutes.AccountVkFlow) },
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
index 362cb35..239579f 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthRegisterDraft.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthRegisterDraft.kt
@@ -1,9 +1,10 @@
package ru.fromchat.ui.auth
+import ru.fromchat.api.schema.user.auth.VkOAuthParams
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
/**
- * Survives [AuthScreen] leaving composition when navigating to the Yandex OAuth route.
+ * Survives [AuthScreen] leaving composition when navigating to an OAuth route.
* Cleared on welcome / successful auth / explicit reset to username.
*/
internal object AuthRegisterDraft {
@@ -12,9 +13,11 @@ internal object AuthRegisterDraft {
var confirmPassword: String = ""
var displayName: String = ""
var bio: String = ""
- var yandexRequired: Boolean = false
+ var verificationRequired: Boolean = false
var yandexParams: YandexOAuthParams? = null
- var registrationProof: String? = null
+ var vkParams: VkOAuthParams? = null
+ var yandexRegistrationProof: String? = null
+ var vkRegistrationProof: String? = null
var page: Int = 0
fun clear() {
@@ -23,9 +26,11 @@ internal object AuthRegisterDraft {
confirmPassword = ""
displayName = ""
bio = ""
- yandexRequired = false
+ verificationRequired = false
yandexParams = null
- registrationProof = null
+ vkParams = null
+ yandexRegistrationProof = null
+ vkRegistrationProof = null
page = 0
}
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt
index e44b9cf..b23876d 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
@@ -30,13 +30,13 @@ import ru.fromchat.api.instance.probeServer
import ru.fromchat.api.schema.core.ErrorResponse
import ru.fromchat.api.schema.user.auth.LoginResponse
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
+import ru.fromchat.api.schema.user.auth.VkOAuthParams
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
@@ -49,15 +49,16 @@ private enum class AuthFlowStep {
Username,
Password,
ConfirmPassword,
- YandexId,
+ IdentityVerify,
Profile,
}
internal sealed interface PasswordStepResult {
data object LoginSuccess : PasswordStepResult
data class NeedsRegister(
- val yandexRequired: Boolean,
+ val verificationRequired: Boolean,
val yandex: YandexOAuthParams?,
+ val vk: VkOAuthParams?,
) : PasswordStepResult
data class WrongPassword(val message: String) : PasswordStepResult
data class RateLimited(val message: String) : PasswordStepResult
@@ -121,8 +122,9 @@ internal suspend fun authPasswordStep(
}
is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> PasswordStepResult.NeedsRegister(
- yandexRequired = outcome.yandexRequired,
+ verificationRequired = outcome.verificationRequired,
yandex = outcome.yandex,
+ vk = outcome.vk,
)
}
} catch (e: ClientRequestException) {
@@ -146,7 +148,8 @@ internal suspend fun register(
displayName: String,
password: String,
bio: String,
- registrationProof: String?,
+ yandexRegistrationProof: String?,
+ vkRegistrationProof: String?,
unexpectedError: String,
) = try {
fullLogin(username.trim(), password.trim()) {
@@ -158,7 +161,8 @@ internal suspend fun register(
password = derived,
confirm_password = derived,
bio = bio.trim().takeIf { it.isNotEmpty() },
- registration_proof = registrationProof,
+ registration_proof = yandexRegistrationProof,
+ vk_registration_proof = vkRegistrationProof,
),
)
}
@@ -214,9 +218,11 @@ fun AuthScreen(
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 verificationRequired by remember { mutableStateOf(AuthRegisterDraft.verificationRequired) }
var yandexParams by remember { mutableStateOf(AuthRegisterDraft.yandexParams) }
- var registrationProof by remember { mutableStateOf(AuthRegisterDraft.registrationProof) }
+ var vkParams by remember { mutableStateOf(AuthRegisterDraft.vkParams) }
+ var yandexRegistrationProof by remember { mutableStateOf(AuthRegisterDraft.yandexRegistrationProof) }
+ var vkRegistrationProof by remember { mutableStateOf(AuthRegisterDraft.vkRegistrationProof) }
fun persistDraft() {
AuthRegisterDraft.username = username
@@ -224,9 +230,11 @@ fun AuthScreen(
AuthRegisterDraft.confirmPassword = confirmPassword
AuthRegisterDraft.displayName = displayName
AuthRegisterDraft.bio = bio
- AuthRegisterDraft.yandexRequired = yandexRequired
+ AuthRegisterDraft.verificationRequired = verificationRequired
AuthRegisterDraft.yandexParams = yandexParams
- AuthRegisterDraft.registrationProof = registrationProof
+ AuthRegisterDraft.vkParams = vkParams
+ AuthRegisterDraft.yandexRegistrationProof = yandexRegistrationProof
+ AuthRegisterDraft.vkRegistrationProof = vkRegistrationProof
AuthRegisterDraft.page = flowState.pagerState.currentPage
}
@@ -255,9 +263,11 @@ fun AuthScreen(
confirmPassword = ""
displayName = ""
bio = ""
- yandexRequired = false
+ verificationRequired = false
yandexParams = null
- registrationProof = null
+ vkParams = null
+ yandexRegistrationProof = null
+ vkRegistrationProof = null
AuthRegisterDraft.clear()
flowState.resetPredictiveState()
scope.launch {
@@ -265,11 +275,27 @@ fun AuthScreen(
}
}
+ fun clearProofs() {
+ yandexRegistrationProof = null
+ vkRegistrationProof = null
+ }
+
DisposableEffect(Unit) {
onDispose { persistDraft() }
}
- LaunchedEffect(username, password, confirmPassword, displayName, bio, yandexRequired, yandexParams, registrationProof) {
+ LaunchedEffect(
+ username,
+ password,
+ confirmPassword,
+ displayName,
+ bio,
+ verificationRequired,
+ yandexParams,
+ vkParams,
+ yandexRegistrationProof,
+ vkRegistrationProof,
+ ) {
persistDraft()
}
@@ -284,7 +310,7 @@ fun AuthScreen(
snapshotFlow { flowState.pagerState.currentPage }
.collect { page ->
AuthRegisterDraft.page = page
- if (page == AuthFlowStep.YandexId.ordinal && !yandexRequired) {
+ if (page == AuthFlowStep.IdentityVerify.ordinal && !verificationRequired) {
val target = if (page > settledPage) {
AuthFlowStep.Profile.ordinal
} else {
@@ -296,37 +322,27 @@ fun AuthScreen(
}
if (page < settledPage) {
when (page) {
- AuthFlowStep.Username.ordinal -> {
+ AuthFlowStep.Username.ordinal,
+ AuthFlowStep.Password.ordinal,
+ -> {
password = ""
confirmPassword = ""
- yandexRequired = false
+ verificationRequired = false
yandexParams = null
- registrationProof = null
+ vkParams = null
+ clearProofs()
}
- AuthFlowStep.Password.ordinal -> {
- password = ""
- confirmPassword = ""
- yandexRequired = false
- yandexParams = null
- registrationProof = null
- }
-
- AuthFlowStep.ConfirmPassword.ordinal -> {
- // Keep confirm password when returning from Yandex ID / OAuth.
- registrationProof = null
- }
-
- AuthFlowStep.YandexId.ordinal -> {
- registrationProof = null
- }
+ AuthFlowStep.ConfirmPassword.ordinal,
+ AuthFlowStep.IdentityVerify.ordinal,
+ -> clearProofs()
}
}
settledPage = page
}
}
- val yandexStep = yandexParams
+ val showVerify = verificationRequired && (yandexParams != null || vkParams != null)
ExpressiveStepFlowScaffold(
flowState = flowState,
pages = listOf(
@@ -343,10 +359,11 @@ fun AuthScreen(
password = password,
onPasswordChange = { password = it },
onLoginSuccess = wrappedAuthSuccess,
- onNeedsRegister = { required, params ->
- yandexRequired = required
- yandexParams = params
- registrationProof = null
+ onNeedsRegister = { required, yandex, vk ->
+ verificationRequired = required
+ yandexParams = yandex
+ vkParams = vk
+ clearProofs()
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
},
onSnackbar = ::snackbar,
@@ -356,19 +373,29 @@ fun AuthScreen(
onConfirmPasswordChange = { confirmPassword = it },
password = password,
onContinue = {
- if (yandexRequired && yandexParams != null) {
- flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
+ if (showVerify) {
+ flowState.pagerState.animateScrollToPage(AuthFlowStep.IdentityVerify.ordinal)
} else {
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
}
},
onSnackbar = ::snackbar,
),
- if (yandexStep != null) {
- yandexIdStepPage(
- yandex = yandexStep,
- onProof = { proof ->
- registrationProof = proof
+ if (showVerify) {
+ identityVerifyStepPage(
+ yandex = yandexParams,
+ vk = vkParams,
+ onProof = { provider, proof ->
+ when (provider) {
+ IdentityProvider.Yandex -> {
+ yandexRegistrationProof = proof
+ vkRegistrationProof = null
+ }
+ IdentityProvider.Vk -> {
+ vkRegistrationProof = proof
+ yandexRegistrationProof = null
+ }
+ }
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
},
onSnackbar = ::snackbar,
@@ -391,7 +418,8 @@ fun AuthScreen(
bio = bio,
onBioChange = { bio = it },
password = password,
- registrationProof = registrationProof,
+ yandexRegistrationProof = yandexRegistrationProof,
+ vkRegistrationProof = vkRegistrationProof,
onRegisterSuccess = wrappedAuthSuccess,
onUsernameTaken = resetToUsername,
onSnackbar = ::snackbar,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/IdentityVerifyStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/IdentityVerifyStep.kt
new file mode 100644
index 0000000..bd35eac
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/IdentityVerifyStep.kt
@@ -0,0 +1,217 @@
+package ru.fromchat.ui.auth
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.VerifiedUser
+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 androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import ru.fromchat.Res
+import ru.fromchat.api.schema.user.auth.VkOAuthParams
+import ru.fromchat.api.schema.user.auth.YandexOAuthParams
+import ru.fromchat.auth.vk.VK_OAUTH_REDIRECT_URI
+import ru.fromchat.auth.vk.buildVkAuthorizeUrl
+import ru.fromchat.auth.vk.generateOAuthState
+import ru.fromchat.auth.vk.generateVkPkcePair
+import ru.fromchat.auth.vk.resolveVkClientId
+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_verify_body
+import ru.fromchat.auth_step_verify_title
+import ru.fromchat.auth_step_vk_cta
+import ru.fromchat.auth_step_yandex_cta
+import ru.fromchat.auth_vk_client_mismatch
+import ru.fromchat.auth_yandex_client_mismatch
+import ru.fromchat.config.Settings
+import ru.fromchat.error_unexpected
+import ru.fromchat.ui.LocalNavController
+import ru.fromchat.ui.auth.vk.VkOAuthNav
+import ru.fromchat.ui.auth.yandex.YandexOAuthNav
+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
+
+enum class IdentityProvider {
+ Yandex,
+ Vk,
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+internal fun identityVerifyStepPage(
+ yandex: YandexOAuthParams?,
+ vk: VkOAuthParams?,
+ onProof: suspend (provider: IdentityProvider, proof: 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 title = stringResource(Res.string.auth_step_verify_title)
+ val body = stringResource(Res.string.auth_step_verify_body)
+ val yandexCta = stringResource(Res.string.auth_step_yandex_cta)
+ val vkCta = stringResource(Res.string.auth_step_vk_cta)
+ val yandexMismatch = stringResource(Res.string.auth_yandex_client_mismatch)
+ val vkMismatch = stringResource(Res.string.auth_vk_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(IdentityProvider.Yandex, 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)
+ }
+ }
+ LaunchedEffect(navController) {
+ val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
+ handle.getStateFlow(VkOAuthNav.RESULT_PROOF, null).collect { proof ->
+ if (proof == null) return@collect
+ handle.remove(VkOAuthNav.RESULT_PROOF)
+ busy = true
+ try {
+ onProofState.value(IdentityProvider.Vk, proof)
+ } finally {
+ busy = false
+ }
+ }
+ }
+ LaunchedEffect(navController) {
+ val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
+ handle.getStateFlow(VkOAuthNav.RESULT_ERROR, null).collect { message ->
+ if (message == null) return@collect
+ handle.remove(VkOAuthNav.RESULT_ERROR)
+ onSnackbarState.value(message, null)
+ }
+ }
+
+ return ExpressiveStepPage(
+ hero = ExpressiveHeroSpec(
+ icon = Icons.Filled.VerifiedUser,
+ polygon = MaterialShapes.Cookie9Sided.normalized(),
+ containerColor = colorScheme.primaryContainer,
+ contentColor = colorScheme.onPrimaryContainer,
+ ),
+ content = {
+ ExpressiveStepPageHeader(title = title, body = body)
+ },
+ button = {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ if (yandex != null) {
+ 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(yandexMismatch, 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(yandexCta)
+ }
+ }
+ if (vk != null) {
+ ActionButton(
+ onClick = {
+ if (busy) return@ActionButton
+ val serverIp = runCatching { Settings.serverConfig.serverIp }.getOrElse {
+ onSnackbar(unexpected, it)
+ return@ActionButton
+ }
+ val clientId = resolveVkClientId(vk.client_id, serverIp)
+ if (clientId == null) {
+ onSnackbar(vkMismatch, null)
+ return@ActionButton
+ }
+ val pkce = generateVkPkcePair()
+ val state = generateOAuthState()
+ val redirectUri = vk.redirect_uri.ifBlank { VK_OAUTH_REDIRECT_URI }
+ VkOAuthNav.pending = VkOAuthNav.Session(
+ authorizeUrl = buildVkAuthorizeUrl(
+ authorizeUrl = vk.authorize_url,
+ clientId = clientId,
+ redirectUri = redirectUri,
+ scope = vk.scope,
+ codeChallenge = pkce.codeChallenge,
+ state = state,
+ languageTag = languageTag,
+ darkTheme = darkTheme,
+ ),
+ codeVerifier = pkce.codeVerifier,
+ state = state,
+ redirectUri = redirectUri,
+ )
+ navController.navigate(VkOAuthNav.ROUTE)
+ },
+ enabled = !busy,
+ loading = busy,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(vkCta)
+ }
+ }
+ }
+ },
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt
index 80998c6..3b18531 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.VkOAuthParams
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
import ru.fromchat.ui.components.ActionButton
import ru.fromchat.ui.components.ExpressiveHeroSpec
@@ -54,7 +55,11 @@ internal fun passwordStepPage(
password: String,
onPasswordChange: (String) -> Unit,
onLoginSuccess: () -> Unit,
- onNeedsRegister: suspend (yandexRequired: Boolean, yandex: YandexOAuthParams?) -> Unit,
+ onNeedsRegister: suspend (
+ verificationRequired: Boolean,
+ yandex: YandexOAuthParams?,
+ vk: VkOAuthParams?,
+ ) -> Unit,
onSnackbar: (String, Throwable?) -> Unit,
): ExpressiveStepPage {
val scope = rememberCoroutineScope()
@@ -135,7 +140,11 @@ internal fun passwordStepPage(
}
is PasswordStepResult.NeedsRegister -> {
- onNeedsRegister(result.yandexRequired, result.yandex)
+ onNeedsRegister(
+ result.verificationRequired,
+ result.yandex,
+ result.vk,
+ )
}
is PasswordStepResult.WrongPassword -> {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/oauth/OAuthWebView.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/oauth/OAuthWebView.kt
new file mode 100644
index 0000000..7479754
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/oauth/OAuthWebView.kt
@@ -0,0 +1,28 @@
+package ru.fromchat.ui.auth.oauth
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Platform WebView for identity OAuth (Yandex, VK ID, …).
+ * Intercepts [redirectUriPrefix] and reports the full redirect URL via [onRedirectUrl].
+ *
+ * @param isAuthNavigation Keep matching hosts in-WebView; open everything else externally.
+ * @param themeCookieHosts Optional hosts that receive light/dark theme cookies before load.
+ */
+@Composable
+expect fun OAuthWebView(
+ authorizeUrl: String,
+ languageTag: String,
+ darkTheme: Boolean,
+ fallbackColor: Color,
+ redirectUriPrefix: String,
+ isAuthNavigation: (url: String) -> Boolean,
+ clearCookies: Boolean = false,
+ themeCookieHosts: List = emptyList(),
+ onPageBackgroundColor: (Color) -> Unit = {},
+ onHistoryBackAvailabilityChanged: (Boolean) -> Unit = {},
+ onRedirectUrl: (String) -> Unit,
+ onError: (String) -> Unit,
+ onCancel: () -> Unit,
+)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt
index d2648b5..59094b5 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,7 +55,8 @@ internal fun profileStepPage(
bio: String,
onBioChange: (String) -> Unit,
password: String,
- registrationProof: String?,
+ yandexRegistrationProof: String?,
+ vkRegistrationProof: String?,
onRegisterSuccess: () -> Unit,
onUsernameTaken: () -> Unit,
onSnackbar: (String, Throwable?) -> Unit,
@@ -148,7 +149,8 @@ internal fun profileStepPage(
displayName = displayName.trim(),
password = password,
bio = bio.trim(),
- registrationProof = registrationProof,
+ yandexRegistrationProof = yandexRegistrationProof,
+ vkRegistrationProof = vkRegistrationProof,
unexpectedError = unexpected,
)
) {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/vk/VkOAuthNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/vk/VkOAuthNav.kt
new file mode 100644
index 0000000..fdc9963
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/vk/VkOAuthNav.kt
@@ -0,0 +1,41 @@
+package ru.fromchat.ui.auth.vk
+
+import androidx.compose.runtime.saveable.listSaver
+import kotlin.concurrent.Volatile
+
+/**
+ * Root [androidx.navigation.NavController] route for the VK OAuth WebView.
+ * Session is staged in [pending] before [navigate]; PKCE verifier and state must not go in the route.
+ * Prefer [SessionSaver] / rememberSaveable on the OAuth screen so pause/recreate keeps PKCE.
+ */
+internal object VkOAuthNav {
+ const val ROUTE = "vkOAuth"
+ const val RESULT_PROOF = "vk_registration_proof"
+ const val RESULT_ERROR = "vk_oauth_error"
+
+ data class Session(
+ val authorizeUrl: String,
+ val codeVerifier: String,
+ val state: String,
+ val redirectUri: String,
+ )
+
+ val SessionSaver = listSaver(
+ save = { session ->
+ if (session == null) emptyList()
+ else listOf(session.authorizeUrl, session.codeVerifier, session.state, session.redirectUri)
+ },
+ restore = { saved ->
+ if (saved.size < 4) null
+ else Session(
+ authorizeUrl = saved[0],
+ codeVerifier = saved[1],
+ state = saved[2],
+ redirectUri = saved[3],
+ )
+ },
+ )
+
+ @Volatile
+ var pending: Session? = null
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/vk/VkOAuthScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/vk/VkOAuthScreen.kt
new file mode 100644
index 0000000..052a7c5
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/vk/VkOAuthScreen.kt
@@ -0,0 +1,180 @@
+package ru.fromchat.ui.auth.vk
+
+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_vk_failed
+import ru.fromchat.back
+import ru.fromchat.ui.auth.vk.VkOAuthWebView
+import ru.fromchat.ui.LocalNavController
+import ru.fromchat.ui.isAppInDarkTheme
+
+private const val LOG_TAG = "VkOAuthScreen"
+
+@Composable
+internal fun VkOAuthScreen() {
+ val navController = LocalNavController.current
+ val scope = rememberCoroutineScope()
+ var session by rememberSaveable(stateSaver = VkOAuthNav.SessionSaver) {
+ mutableStateOf(VkOAuthNav.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_vk_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=${VkOAuthNav.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 {
+ VkOAuthNav.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")
+ VkOAuthNav.pending = null
+ navController.previousBackStackEntry
+ ?.savedStateHandle
+ ?.set(VkOAuthNav.RESULT_PROOF, proof)
+ navController.popBackStack()
+ }
+
+ fun finishWithError(message: String) {
+ Logger.w(LOG_TAG, "finishWithError id=$screenId message=$message")
+ VkOAuthNav.pending = null
+ navController.previousBackStackEntry
+ ?.savedStateHandle
+ ?.set(VkOAuthNav.RESULT_ERROR, message)
+ navController.popBackStack()
+ }
+
+ fun cancel() {
+ if (busy) return
+ Logger.i(LOG_TAG, "cancel id=$screenId")
+ VkOAuthNav.pending = null
+ navController.popBackStack()
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(chromeColor),
+ ) {
+ VkOAuthWebView(
+ authorizeUrl = active.authorizeUrl,
+ redirectUri = active.redirectUri,
+ languageTag = Locale.current.toLanguageTag(),
+ darkTheme = darkTheme,
+ fallbackColor = fallbackColor,
+ onPageBackgroundColor = { chromeColor = it },
+ onHistoryBackAvailabilityChanged = { webViewCanGoBack = it },
+ onRedirect = { redirect ->
+ if (busy) return@VkOAuthWebView
+ if (redirect.state != active.state) {
+ finishWithError(failedMessage)
+ return@VkOAuthWebView
+ }
+ scope.launch {
+ busy = true
+ try {
+ val proof = ApiClient.authVkExchange(
+ code = redirect.code,
+ codeVerifier = active.codeVerifier,
+ deviceId = redirect.deviceId,
+ state = redirect.state,
+ ).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() },
+ )
+
+ 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/vk/VkOAuthWebView.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/vk/VkOAuthWebView.kt
new file mode 100644
index 0000000..b26c9ab
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/vk/VkOAuthWebView.kt
@@ -0,0 +1,50 @@
+package ru.fromchat.ui.auth.vk
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import ru.fromchat.auth.vk.VK_OAUTH_REDIRECT_URI
+import ru.fromchat.auth.vk.VK_OAUTH_THEME_COOKIE_HOSTS
+import ru.fromchat.auth.vk.VkOAuthRedirect
+import ru.fromchat.auth.vk.extractVkOAuthRedirect
+import ru.fromchat.auth.vk.isVkAuthNavigation
+import ru.fromchat.ui.auth.oauth.OAuthWebView
+
+/**
+ * VK-specific wrapper around [OAuthWebView].
+ *
+ * @param redirectUri Trusted HTTPS redirect from the server (must match VK ID cabinet).
+ */
+@Composable
+internal fun VkOAuthWebView(
+ authorizeUrl: String,
+ redirectUri: String,
+ languageTag: String,
+ darkTheme: Boolean,
+ fallbackColor: Color,
+ clearCookies: Boolean = false,
+ onPageBackgroundColor: (Color) -> Unit = {},
+ onHistoryBackAvailabilityChanged: (Boolean) -> Unit = {},
+ onRedirect: (VkOAuthRedirect) -> Unit,
+ onError: (String) -> Unit,
+ onCancel: () -> Unit,
+) {
+ val resolvedRedirect = redirectUri.trim().ifBlank { VK_OAUTH_REDIRECT_URI }
+ OAuthWebView(
+ authorizeUrl = authorizeUrl,
+ languageTag = languageTag,
+ darkTheme = darkTheme,
+ fallbackColor = fallbackColor,
+ redirectUriPrefix = resolvedRedirect,
+ isAuthNavigation = ::isVkAuthNavigation,
+ clearCookies = clearCookies,
+ themeCookieHosts = VK_OAUTH_THEME_COOKIE_HOSTS,
+ onPageBackgroundColor = onPageBackgroundColor,
+ onHistoryBackAvailabilityChanged = onHistoryBackAvailabilityChanged,
+ onRedirectUrl = { url ->
+ val redirect = extractVkOAuthRedirect(url, resolvedRedirect)
+ if (redirect != null) onRedirect(redirect) else onError("")
+ },
+ onError = onError,
+ onCancel = onCancel,
+ )
+}
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
deleted file mode 100644
index f38cef9..0000000
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/yandex/YandexIdStep.kt
+++ /dev/null
@@ -1,130 +0,0 @@
-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/YandexOAuthWebView.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.kt
index 353197e..e93da5b 100644
--- 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
@@ -2,19 +2,17 @@ package ru.fromchat.ui.auth.yandex
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
+import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
+import ru.fromchat.auth.yandex.YANDEX_OAUTH_THEME_COOKIE_HOSTS
+import ru.fromchat.auth.yandex.extractOAuthCode
+import ru.fromchat.auth.yandex.isYandexAuthNavigation
+import ru.fromchat.ui.auth.oauth.OAuthWebView
/**
- * 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.
+ * Yandex-specific wrapper around [OAuthWebView].
*/
@Composable
-expect fun YandexOAuthWebView(
+internal fun YandexOAuthWebView(
authorizeUrl: String,
languageTag: String,
darkTheme: Boolean,
@@ -25,4 +23,23 @@ expect fun YandexOAuthWebView(
onCode: (String) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit,
-)
+) {
+ OAuthWebView(
+ authorizeUrl = authorizeUrl,
+ languageTag = languageTag,
+ darkTheme = darkTheme,
+ fallbackColor = fallbackColor,
+ redirectUriPrefix = YANDEX_OAUTH_REDIRECT_URI,
+ isAuthNavigation = ::isYandexAuthNavigation,
+ clearCookies = clearCookies,
+ themeCookieHosts = YANDEX_OAUTH_THEME_COOKIE_HOSTS,
+ onPageBackgroundColor = onPageBackgroundColor,
+ onHistoryBackAvailabilityChanged = onHistoryBackAvailabilityChanged,
+ onRedirectUrl = { url ->
+ val code = extractOAuthCode(url)
+ if (code != null) onCode(code) else onError("")
+ },
+ onError = onError,
+ onCancel = onCancel,
+ )
+}
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 574b1d1..6113b07 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
@@ -16,6 +16,9 @@ object SettingsRoutes {
const val AccountYandexFlow = "settings/account/yandex"
const val AccountYandexOAuth = "settings/account/yandex/oauth"
const val AccountYandexDone = "settings/account/yandex/done"
+ const val AccountVkFlow = "settings/account/vk"
+ const val AccountVkOAuth = "settings/account/vk/oauth"
+ const val AccountVkDone = "settings/account/vk/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 423c9cd..45c2662 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
@@ -43,8 +43,11 @@ import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.back
import ru.fromchat.cancel
+import ru.fromchat.ic_vk
import ru.fromchat.ic_yandex
import ru.fromchat.logout
+import ru.fromchat.settings_account_change_vk
+import ru.fromchat.settings_account_change_vk_d
import ru.fromchat.settings_account_change_yandex
import ru.fromchat.settings_account_change_yandex_d
import ru.fromchat.settings_account_delete
@@ -63,16 +66,20 @@ fun AccountScreen(
onLogout: () -> Unit,
onChangePassword: () -> Unit,
onChangeYandexId: () -> Unit,
+ onChangeVkId: () -> Unit,
onDeleteAccount: () -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val scope = rememberCoroutineScope()
var showLogoutConfirm by remember { mutableStateOf(false) }
var yandexAvailable by remember { mutableStateOf(false) }
+ var vkAvailable by remember { mutableStateOf(false) }
val yandexIcon = vectorResource(Res.drawable.ic_yandex)
+ val vkIcon = vectorResource(Res.drawable.ic_vk)
LaunchedEffect(Unit) {
yandexAvailable = runCatching { ApiClient.getAccountYandex() }.isSuccess
+ vkAvailable = runCatching { ApiClient.getAccountVk() }.isSuccess
}
Scaffold(
@@ -133,6 +140,24 @@ fun AccountScreen(
)
}
+ if (vkAvailable) {
+ ListItem(
+ headline = stringResource(Res.string.settings_account_change_vk),
+ supportingText = stringResource(Res.string.settings_account_change_vk_d),
+ onClick = onChangeVkId,
+ leadingContent = { Icon(vkIcon, 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/changevk/ChangeVkConfirmScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changevk/ChangeVkConfirmScreen.kt
new file mode 100644
index 0000000..93c6a43
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changevk/ChangeVkConfirmScreen.kt
@@ -0,0 +1,161 @@
+package ru.fromchat.ui.main.settings.account.changevk
+
+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.VkOAuthParams
+import ru.fromchat.auth.vk.VK_OAUTH_REDIRECT_URI
+import ru.fromchat.auth.vk.buildVkAuthorizeUrl
+import ru.fromchat.auth.vk.generateOAuthState
+import ru.fromchat.auth.vk.generateVkPkcePair
+import ru.fromchat.auth.vk.resolveVkClientId
+import ru.fromchat.auth_vk_client_mismatch
+import ru.fromchat.config.Settings
+import ru.fromchat.error_unexpected
+import ru.fromchat.ic_vk
+import ru.fromchat.settings_next
+import ru.fromchat.settings_vk_step_confirm_body
+import ru.fromchat.settings_vk_step_confirm_cta
+import ru.fromchat.settings_vk_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 ChangeVkConfirmScreen(onBack: () -> Unit) {
+ val navController = LocalNavController.current
+ val scope = rememberCoroutineScope()
+ val snackbarHostState = remember { SnackbarHostState() }
+ val flowState = rememberExpressiveStepFlow(1)
+ var vk 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 vkIcon = vectorResource(Res.drawable.ic_vk)
+
+ val title = stringResource(Res.string.settings_vk_step_confirm_title)
+ val body = stringResource(Res.string.settings_vk_step_confirm_body)
+ val cta = stringResource(Res.string.settings_vk_step_confirm_cta)
+ val next = stringResource(Res.string.settings_next)
+ val clientMismatch = stringResource(Res.string.auth_vk_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) {
+ ChangeVkDraft.clear()
+ loadingParams = true
+ try {
+ vk = ApiClient.getAccountVk().vk
+ } catch (e: Exception) {
+ showSnack(e.message ?: unexpected)
+ } finally {
+ loadingParams = false
+ }
+ }
+
+ val colorScheme = MaterialTheme.colorScheme
+ ExpressiveStepFlowScaffold(
+ flowState = flowState,
+ pages = listOf(
+ ExpressiveStepPage(
+ hero = ExpressiveHeroSpec(
+ icon = vkIcon,
+ 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 = vk
+ 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 = resolveVkClientId(params.client_id, serverIp)
+ if (clientId == null) {
+ showSnack(clientMismatch)
+ return@launch
+ }
+ val pkce = generateVkPkcePair()
+ val state = generateOAuthState()
+ val redirectUri = params.redirect_uri.ifBlank { VK_OAUTH_REDIRECT_URI }
+ ChangeVkDraft.authorizeUrl = buildVkAuthorizeUrl(
+ authorizeUrl = params.authorize_url,
+ clientId = clientId,
+ redirectUri = redirectUri,
+ scope = params.scope,
+ codeChallenge = pkce.codeChallenge,
+ state = state,
+ languageTag = languageTag,
+ darkTheme = darkTheme,
+ )
+ ChangeVkDraft.codeVerifier = pkce.codeVerifier
+ ChangeVkDraft.state = state
+ ChangeVkDraft.redirectUri = redirectUri
+ navController.navigate(SettingsRoutes.AccountVkOAuth)
+ } finally {
+ busy = false
+ }
+ }
+ },
+ enabled = !busy && !loadingParams && vk != 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/changevk/ChangeVkDoneScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changevk/ChangeVkDoneScreen.kt
new file mode 100644
index 0000000..cc5bc1b
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changevk/ChangeVkDoneScreen.kt
@@ -0,0 +1,62 @@
+package ru.fromchat.ui.main.settings.account.changevk
+
+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_vk_step_done_body
+import ru.fromchat.settings_vk_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 ChangeVkDoneScreen(onDone: () -> Unit) {
+ val flowState = rememberExpressiveStepFlow(1)
+ val snackbarHostState = remember { SnackbarHostState() }
+ val title = stringResource(Res.string.settings_vk_step_done_title)
+ val body = stringResource(Res.string.settings_vk_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/changevk/ChangeVkDraft.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changevk/ChangeVkDraft.kt
new file mode 100644
index 0000000..f0ea8cb
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changevk/ChangeVkDraft.kt
@@ -0,0 +1,19 @@
+package ru.fromchat.ui.main.settings.account.changevk
+
+/**
+ * Stages PKCE + authorize URL + OAuth state while the change-VK OAuth WebView is open
+ * (settings composition can leave the confirm screen).
+ */
+internal object ChangeVkDraft {
+ var authorizeUrl: String? = null
+ var codeVerifier: String? = null
+ var state: String? = null
+ var redirectUri: String? = null
+
+ fun clear() {
+ authorizeUrl = null
+ codeVerifier = null
+ state = null
+ redirectUri = null
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changevk/ChangeVkOAuthScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changevk/ChangeVkOAuthScreen.kt
new file mode 100644
index 0000000..758c800
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/account/changevk/ChangeVkOAuthScreen.kt
@@ -0,0 +1,142 @@
+package ru.fromchat.ui.main.settings.account.changevk
+
+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.vk.VkOAuthWebView
+import ru.fromchat.ui.isAppInDarkTheme
+import ru.fromchat.ui.main.settings.SettingsRoutes
+
+@Composable
+fun ChangeVkOAuthScreen(onBack: () -> Unit) {
+ val navController = LocalNavController.current
+ val scope = rememberCoroutineScope()
+ val authorizeUrl = remember { ChangeVkDraft.authorizeUrl }
+ val codeVerifier = remember { ChangeVkDraft.codeVerifier }
+ val expectedState = remember { ChangeVkDraft.state }
+ val redirectUri = remember { ChangeVkDraft.redirectUri }
+ 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, expectedState, redirectUri) {
+ if (authorizeUrl.isNullOrBlank() ||
+ codeVerifier.isNullOrBlank() ||
+ expectedState.isNullOrBlank() ||
+ redirectUri.isNullOrBlank()
+ ) {
+ onBack()
+ }
+ }
+
+ val url = authorizeUrl ?: return
+ val verifier = codeVerifier ?: return
+ val state = expectedState ?: return
+ val callbackUri = redirectUri ?: return
+
+ fun finishSuccess() {
+ ChangeVkDraft.clear()
+ navController.navigate(SettingsRoutes.AccountVkDone) {
+ popUpTo(SettingsRoutes.AccountVkFlow) { inclusive = true }
+ }
+ }
+
+ fun cancel() {
+ if (busy) return
+ ChangeVkDraft.clear()
+ onBack()
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(chromeColor),
+ ) {
+ VkOAuthWebView(
+ authorizeUrl = url,
+ redirectUri = callbackUri,
+ languageTag = Locale.current.toLanguageTag(),
+ darkTheme = isAppInDarkTheme(),
+ fallbackColor = fallbackColor,
+ clearCookies = true,
+ onPageBackgroundColor = { chromeColor = it },
+ onHistoryBackAvailabilityChanged = { webViewCanGoBack = it },
+ onRedirect = { redirect ->
+ if (busy) return@VkOAuthWebView
+ if (redirect.state != state) {
+ cancel()
+ return@VkOAuthWebView
+ }
+ scope.launch {
+ busy = true
+ try {
+ val proof = ApiClient.authVkExchange(
+ code = redirect.code,
+ codeVerifier = verifier,
+ deviceId = redirect.deviceId,
+ state = redirect.state,
+ ).registration_proof
+ ApiClient.changeAccountVk(proof)
+ finishSuccess()
+ } catch (_: Exception) {
+ cancel()
+ } finally {
+ busy = false
+ }
+ }
+ },
+ onError = { cancel() },
+ onCancel = { cancel() },
+ )
+
+ 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/oauth/OAuthWebView.ios.kt
similarity index 62%
rename from app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.ios.kt
rename to app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/oauth/OAuthWebView.ios.kt
index d0b9f96..97d7920 100644
--- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/yandex/YandexOAuthWebView.ios.kt
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/auth/oauth/OAuthWebView.ios.kt
@@ -1,23 +1,26 @@
-package ru.fromchat.ui.auth.yandex
+package ru.fromchat.ui.auth.oauth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.graphics.Color
@Composable
-actual fun YandexOAuthWebView(
+actual fun OAuthWebView(
authorizeUrl: String,
languageTag: String,
darkTheme: Boolean,
fallbackColor: Color,
+ redirectUriPrefix: String,
+ isAuthNavigation: (url: String) -> Boolean,
clearCookies: Boolean,
+ themeCookieHosts: List,
onPageBackgroundColor: (Color) -> Unit,
onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
- onCode: (String) -> Unit,
+ onRedirectUrl: (String) -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit,
) {
LaunchedEffect(authorizeUrl) {
- onError("Yandex sign-in is not available on this platform yet.")
+ onError("OAuth sign-in is not available on this platform yet.")
}
}