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