mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Compare commits
5 Commits
@@ -1,11 +0,0 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gradle" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
+3
-1
@@ -31,4 +31,6 @@ google-services.json
|
||||
*.log
|
||||
|
||||
premortem-transcript-*.md
|
||||
premortem-report-*.html
|
||||
premortem-report-*.html
|
||||
|
||||
target/
|
||||
@@ -54,15 +54,6 @@
|
||||
android:host="oauth"
|
||||
android:pathPrefix="/yandex" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="fromchat"
|
||||
android:host="oauth"
|
||||
android:pathPrefix="/vk" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".fcm.FromChatFirebaseMessagingService"
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
package ru.fromchat.ui.auth.captcha
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.net.http.SslError
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.SslErrorHandler
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ru.fromchat.Logger
|
||||
|
||||
private const val SMARTCAPTCHA_WEBVIEW_BASE = "https://smartcaptcha.cloud.yandex.ru/webview"
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
actual fun SmartCaptchaWebView(
|
||||
sitekey: String,
|
||||
languageTag: String,
|
||||
modifier: Modifier,
|
||||
onToken: (String) -> Unit,
|
||||
onReady: () -> Unit,
|
||||
onChallengeVisible: () -> Unit,
|
||||
onChallengeHidden: () -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
val onTokenState = rememberUpdatedState(onToken)
|
||||
val onReadyState = rememberUpdatedState(onReady)
|
||||
val onChallengeVisibleState = rememberUpdatedState(onChallengeVisible)
|
||||
val onChallengeHiddenState = rememberUpdatedState(onChallengeHidden)
|
||||
val onErrorState = rememberUpdatedState(onError)
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val backgroundArgb = MaterialTheme.colorScheme.surfaceContainer.toArgb()
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
val instanceId = remember { Integer.toHexString(System.identityHashCode(Any())) }
|
||||
|
||||
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
|
||||
val captchaUrl = remember(sitekey, lang) {
|
||||
"$SMARTCAPTCHA_WEBVIEW_BASE?sitekey=${sitekey.trim()}&hl=$lang"
|
||||
}
|
||||
|
||||
DisposableEffect(instanceId) {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"WebView compose enter id=$instanceId sitekey=${SmartCaptchaLog.redactKey(sitekey)} " +
|
||||
"lang=$lang languageTag=$languageTag url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
|
||||
)
|
||||
onDispose {
|
||||
Logger.i(SmartCaptchaLog.TAG, "WebView compose dispose id=$instanceId")
|
||||
}
|
||||
}
|
||||
|
||||
val bridge = remember {
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
object {
|
||||
@JavascriptInterface
|
||||
fun onGetToken(token: String) {
|
||||
val cleaned = token.trim()
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"JS onGetToken id=$instanceId ${SmartCaptchaLog.redactToken(cleaned)}",
|
||||
)
|
||||
mainHandler.post {
|
||||
if (cleaned.isNotEmpty()) {
|
||||
onTokenState.value(cleaned)
|
||||
} else {
|
||||
Logger.w(SmartCaptchaLog.TAG, "JS onGetToken empty id=$instanceId")
|
||||
onErrorState.value("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun onChallengeVisible() {
|
||||
Logger.i(SmartCaptchaLog.TAG, "JS onChallengeVisible id=$instanceId")
|
||||
mainHandler.post { onChallengeVisibleState.value() }
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun onChallengeHidden() {
|
||||
Logger.i(SmartCaptchaLog.TAG, "JS onChallengeHidden id=$instanceId")
|
||||
mainHandler.post { onChallengeHiddenState.value() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(webView, lifecycleOwner) {
|
||||
val wv = webView ?: return@DisposableEffect onDispose { }
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
Logger.d(
|
||||
SmartCaptchaLog.TAG,
|
||||
"lifecycle $event id=$instanceId url=${SmartCaptchaLog.shortUrl(wv.url)} " +
|
||||
"progress=${wv.progress}",
|
||||
)
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> wv.onPause()
|
||||
Lifecycle.Event.ON_RESUME -> wv.onResume()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
wv.onResume()
|
||||
}
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
wv.onPause()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
Logger.i(SmartCaptchaLog.TAG, "AndroidView.factory id=$instanceId")
|
||||
WebView(context).apply {
|
||||
setBackgroundColor(backgroundArgb)
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
addJavascriptInterface(bridge, "NativeClient")
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
): Boolean {
|
||||
val url = request?.url?.toString()
|
||||
val host = request?.url?.host?.lowercase().orEmpty()
|
||||
val block = host.isNotEmpty() &&
|
||||
!host.endsWith("yandex.ru") &&
|
||||
!host.endsWith("yandex.com") &&
|
||||
!host.endsWith("yandex.net")
|
||||
Logger.d(
|
||||
SmartCaptchaLog.TAG,
|
||||
"shouldOverrideUrlLoading id=$instanceId block=$block " +
|
||||
"host=$host url=${SmartCaptchaLog.shortUrl(url)}",
|
||||
)
|
||||
return block
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"onPageStarted id=$instanceId url=${SmartCaptchaLog.shortUrl(url)}",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"onPageFinished id=$instanceId progress=${view?.progress} " +
|
||||
"url=${SmartCaptchaLog.shortUrl(url)}",
|
||||
)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
onReadyState.value()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
error: WebResourceError?,
|
||||
) {
|
||||
Logger.w(
|
||||
SmartCaptchaLog.TAG,
|
||||
"onReceivedError id=$instanceId main=${request?.isForMainFrame} " +
|
||||
"code=${error?.errorCode} desc=${error?.description} " +
|
||||
"url=${SmartCaptchaLog.shortUrl(request?.url?.toString())}",
|
||||
)
|
||||
if (request?.isForMainFrame == true) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
onErrorState.value(error?.description?.toString().orEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedHttpError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
errorResponse: WebResourceResponse?,
|
||||
) {
|
||||
Logger.w(
|
||||
SmartCaptchaLog.TAG,
|
||||
"onReceivedHttpError id=$instanceId main=${request?.isForMainFrame} " +
|
||||
"status=${errorResponse?.statusCode} " +
|
||||
"url=${SmartCaptchaLog.shortUrl(request?.url?.toString())}",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onReceivedSslError(
|
||||
view: WebView?,
|
||||
handler: SslErrorHandler?,
|
||||
error: SslError?,
|
||||
) {
|
||||
Logger.e(
|
||||
SmartCaptchaLog.TAG,
|
||||
"onReceivedSslError id=$instanceId primary=${error?.primaryError} " +
|
||||
"url=${SmartCaptchaLog.shortUrl(error?.url)}",
|
||||
)
|
||||
handler?.cancel()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
onErrorState.value("SSL error")
|
||||
}
|
||||
}
|
||||
}
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"loadUrl id=$instanceId url=${SmartCaptchaLog.shortUrl(captchaUrl)}",
|
||||
)
|
||||
loadUrl(captchaUrl)
|
||||
webView = this
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxSize(),
|
||||
update = { wv ->
|
||||
wv.setBackgroundColor(backgroundArgb)
|
||||
webView = wv
|
||||
},
|
||||
onRelease = { wv ->
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"AndroidView.onRelease id=$instanceId url=${SmartCaptchaLog.shortUrl(wv.url)}",
|
||||
)
|
||||
wv.removeJavascriptInterface("NativeClient")
|
||||
wv.stopLoading()
|
||||
wv.destroy()
|
||||
if (webView === wv) webView = null
|
||||
},
|
||||
)
|
||||
}
|
||||
+70
-36
@@ -1,4 +1,4 @@
|
||||
package ru.fromchat.ui.auth.oauth
|
||||
package ru.fromchat.ui.auth.yandex
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
@@ -57,10 +57,12 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
|
||||
import ru.fromchat.auth.yandex.extractOAuthCode
|
||||
import ru.fromchat.ui.components.PredictiveBackHandler
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private const val LOG_TAG = "OAuthWebView"
|
||||
private const val LOG_TAG = "YandexOAuthWV"
|
||||
private const val LOADING_FADE_MS = 250
|
||||
private const val LOADING_HIDE_DELAY_MS = 500L
|
||||
private const val TOP_ROW_SAMPLE_INTERVAL_MS = 50L
|
||||
@@ -133,18 +135,15 @@ private const val PAGE_SCROLL_Y_JS = """
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
actual fun OAuthWebView(
|
||||
actual fun YandexOAuthWebView(
|
||||
authorizeUrl: String,
|
||||
languageTag: String,
|
||||
darkTheme: Boolean,
|
||||
fallbackColor: Color,
|
||||
redirectUriPrefix: String,
|
||||
isAuthNavigation: (url: String) -> Boolean,
|
||||
clearCookies: Boolean,
|
||||
themeCookieHosts: List<String>,
|
||||
onPageBackgroundColor: (Color) -> Unit,
|
||||
onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
|
||||
onRedirectUrl: (String) -> Unit,
|
||||
onCode: (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
@@ -159,10 +158,8 @@ actual fun OAuthWebView(
|
||||
val scheme = if (darkTheme) "dark" else "light"
|
||||
val onHistoryBackAvailabilityChangedState = rememberUpdatedState(onHistoryBackAvailabilityChanged)
|
||||
val onPageBackgroundColorState = rememberUpdatedState(onPageBackgroundColor)
|
||||
val onRedirectUrlState = rememberUpdatedState(onRedirectUrl)
|
||||
val onCodeState = rememberUpdatedState(onCode)
|
||||
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())) }
|
||||
@@ -196,7 +193,7 @@ actual fun OAuthWebView(
|
||||
}
|
||||
}
|
||||
|
||||
ApplyOAuthWebViewSystemBars(
|
||||
ApplyYandexWebViewSystemBars(
|
||||
chromeColor = chromeColor,
|
||||
darkTheme = darkTheme,
|
||||
restoreSurfaceColor = fallbackColor,
|
||||
@@ -272,7 +269,7 @@ actual fun OAuthWebView(
|
||||
onCancel = { },
|
||||
)
|
||||
|
||||
val client = remember(authorizeUrl, lang, darkTheme, redirectUriPrefix) {
|
||||
val client = remember(authorizeUrl, lang, darkTheme) {
|
||||
Logger.i(
|
||||
LOG_TAG,
|
||||
"WebViewClient create id=$instanceId darkTheme=$darkTheme lang=$lang " +
|
||||
@@ -281,16 +278,17 @@ actual fun OAuthWebView(
|
||||
object : WebViewClient() {
|
||||
private fun handleSpecialUrl(view: WebView?, url: String?): Boolean {
|
||||
if (url.isNullOrBlank()) return false
|
||||
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)
|
||||
) {
|
||||
if (url.startsWith("fromchat://", ignoreCase = true)) {
|
||||
Logger.i(LOG_TAG, "intercept redirect url=${shortUrl(url)} id=$instanceId")
|
||||
onRedirectUrlState.value(url)
|
||||
val code = extractOAuthCode(url)
|
||||
if (code != null) {
|
||||
onCodeState.value(code)
|
||||
} else {
|
||||
onErrorState.value("")
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (!isAuthNavigationState.value(url)) {
|
||||
if (!isYandexAuthNavigation(url)) {
|
||||
Logger.d(LOG_TAG, "external nav url=${shortUrl(url)} id=$instanceId")
|
||||
view?.context?.let { ctx ->
|
||||
runCatching {
|
||||
@@ -329,11 +327,7 @@ actual fun OAuthWebView(
|
||||
"wv=${view?.let { Integer.toHexString(System.identityHashCode(it)) }} " +
|
||||
"canGoBack=${view?.canGoBack()} stack=${Throwable().stackTraceToString().lineSequence().take(8).joinToString(" ← ")}",
|
||||
)
|
||||
if (url != null && (
|
||||
url.startsWith(redirectUriPrefixState.value, ignoreCase = true) ||
|
||||
url.startsWith("fromchat://", ignoreCase = true)
|
||||
)
|
||||
) {
|
||||
if (url != null && url.startsWith(YANDEX_OAUTH_REDIRECT_URI, ignoreCase = true)) {
|
||||
handleSpecialUrl(view, url)
|
||||
}
|
||||
pageLoading = true
|
||||
@@ -380,16 +374,16 @@ actual fun OAuthWebView(
|
||||
"stack=${Throwable().stackTraceToString().lineSequence().drop(1).take(10).joinToString(" ← ")}",
|
||||
)
|
||||
if (clearCookies) {
|
||||
clearOAuthWebViewCookies()
|
||||
clearYandexWebViewCookies()
|
||||
}
|
||||
seedOAuthThemeCookies(darkTheme, themeCookieHosts)
|
||||
seedYandexThemeCookies(darkTheme)
|
||||
WebView(activity).apply {
|
||||
setBackgroundColor(fallbackColor.toArgb())
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.javaScriptCanOpenWindowsAutomatically = true
|
||||
settings.setSupportMultipleWindows(true)
|
||||
applyOAuthDarkSettingsIfNeeded(this, darkTheme)
|
||||
applyYandexDarkSettingsIfNeeded(this, darkTheme)
|
||||
webViewClient = client
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onCreateWindow(
|
||||
@@ -400,14 +394,14 @@ actual fun OAuthWebView(
|
||||
): Boolean {
|
||||
val transport = resultMsg?.obj as? WebView.WebViewTransport ?: return false
|
||||
val temp = WebView(activity).apply {
|
||||
applyOAuthDarkSettingsIfNeeded(this, darkTheme)
|
||||
applyYandexDarkSettingsIfNeeded(this, darkTheme)
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
v: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
): Boolean {
|
||||
val url = request?.url?.toString() ?: return false
|
||||
if (!isAuthNavigationState.value(url)) {
|
||||
if (!isYandexAuthNavigation(url)) {
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse(url)),
|
||||
@@ -457,7 +451,7 @@ actual fun OAuthWebView(
|
||||
update = { wv ->
|
||||
val clientChanged = wv.webViewClient !== client
|
||||
val darkBefore = wv.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
|
||||
applyOAuthDarkSettingsIfNeeded(wv, darkTheme)
|
||||
applyYandexDarkSettingsIfNeeded(wv, darkTheme)
|
||||
if (clientChanged) {
|
||||
Logger.i(
|
||||
LOG_TAG,
|
||||
@@ -506,7 +500,7 @@ actual fun OAuthWebView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ApplyOAuthWebViewSystemBars(
|
||||
private fun ApplyYandexWebViewSystemBars(
|
||||
chromeColor: Color,
|
||||
darkTheme: Boolean,
|
||||
restoreSurfaceColor: Color,
|
||||
@@ -563,19 +557,26 @@ private fun Context.findActivity(): Activity? {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun clearOAuthWebViewCookies() {
|
||||
private fun clearYandexWebViewCookies() {
|
||||
val cookieManager = CookieManager.getInstance()
|
||||
cookieManager.setAcceptCookie(true)
|
||||
cookieManager.removeAllCookies(null)
|
||||
cookieManager.flush()
|
||||
Logger.i(LOG_TAG, "cleared all WebView cookies for OAuth re-auth")
|
||||
Logger.i(LOG_TAG, "cleared all WebView cookies for Yandex re-auth")
|
||||
}
|
||||
|
||||
private fun seedOAuthThemeCookies(darkTheme: Boolean, hosts: List<String>) {
|
||||
if (hosts.isEmpty()) return
|
||||
private fun seedYandexThemeCookies(darkTheme: Boolean) {
|
||||
val theme = if (darkTheme) "dark" else "light"
|
||||
val cookieManager = CookieManager.getInstance()
|
||||
cookieManager.setAcceptCookie(true)
|
||||
val hosts = listOf(
|
||||
"https://yandex.ru",
|
||||
"https://yandex.com",
|
||||
"https://passport.yandex.ru",
|
||||
"https://passport.yandex.com",
|
||||
"https://oauth.yandex.ru",
|
||||
"https://oauth.yandex.com",
|
||||
)
|
||||
for (host in hosts) {
|
||||
cookieManager.setCookie(host, "color_scheme=$theme; path=/")
|
||||
cookieManager.setCookie(host, "theme=$theme; path=/")
|
||||
@@ -589,7 +590,7 @@ private fun seedOAuthThemeCookies(darkTheme: Boolean, hosts: List<String>) {
|
||||
* Re-applying the same force-dark value can reload the page and wipe in-progress OAuth UI.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun applyOAuthDarkSettingsIfNeeded(webView: WebView, darkTheme: Boolean) {
|
||||
private fun applyYandexDarkSettingsIfNeeded(webView: WebView, darkTheme: Boolean) {
|
||||
val previous = webView.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
|
||||
if (previous == darkTheme) return
|
||||
Logger.w(
|
||||
@@ -670,3 +671,36 @@ 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"))
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<!-- VK Compact Logo (2021–present) letter mark only; brand blue background removed for tintable icons. -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M25.54,34.5801C14.6,34.5801 8.3601,27.0801 8.1001,14.6001H13.5801C13.7601,23.7601 17.8,27.6401 21,28.4401V14.6001H26.1602V22.5001C29.3202,22.1601 32.6398,18.5601 33.7598,14.6001H38.9199C38.0599,19.4801 34.4599,23.0801 31.8999,24.5601C34.4599,25.7601 38.5601,28.9001 40.1201,34.5801H34.4399C33.2199,30.7801 30.1802,27.8401 26.1602,27.4401V34.5801H25.54Z" />
|
||||
</vector>
|
||||
@@ -55,14 +55,12 @@
|
||||
<string name="auth_step_yandex_title">Войдите через Яндекс ID</string>
|
||||
<string name="auth_step_yandex_body">Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. От Яндекса мы получаем только email — и мы его не сохраняем: вход нужен лишь для защиты.</string>
|
||||
<string name="auth_step_yandex_cta">Продолжить через Яндекс ID</string>
|
||||
<string name="auth_step_verify_title">Подтвердите аккаунт</string>
|
||||
<string name="auth_step_verify_body">Выберите Яндекс ID или VK ID. Мы используем это только против ботов и для соблюдения законов — email и профиль этих сервисов не сохраняем.</string>
|
||||
<string name="auth_step_vk_cta">Продолжить через VK ID</string>
|
||||
<string name="auth_captcha_title">Быстрая проверка</string>
|
||||
<string name="auth_captcha_body">Подтвердите, что вы человек, чтобы продолжить создание аккаунта.</string>
|
||||
<string name="auth_captcha_failed">Не удалось пройти проверку. Попробуйте ещё раз.</string>
|
||||
<string name="auth_yandex_webview_title">Яндекс ID</string>
|
||||
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
|
||||
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
|
||||
<string name="auth_vk_client_mismatch">Сервер вернул неожиданный идентификатор приложения VK. Обновите приложение или обратитесь в поддержку.</string>
|
||||
<string name="auth_vk_failed">Вход через VK ID отменён или не удался.</string>
|
||||
<string name="chats">Чаты</string>
|
||||
<string name="contacts">Контакты</string>
|
||||
<string name="profile">Профиль</string>
|
||||
@@ -438,13 +436,6 @@
|
||||
<string name="settings_yandex_step_confirm_cta">Продолжить</string>
|
||||
<string name="settings_yandex_step_done_title">Яндекс ID обновлён</string>
|
||||
<string name="settings_yandex_step_done_body">Аккаунт теперь привязан к Яндекс ID, с которым вы только что вошли.</string>
|
||||
<string name="settings_account_change_vk">Сменить VK ID</string>
|
||||
<string name="settings_account_change_vk_d">Привязать другой аккаунт VK</string>
|
||||
<string name="settings_vk_step_confirm_title">Сменить VK ID?</string>
|
||||
<string name="settings_vk_step_confirm_body">Это не удаляет ваш аккаунт FromChat. Предыдущий VK ID будет освобождён, а вместо него привяжется тот, с которым вы войдёте.</string>
|
||||
<string name="settings_vk_step_confirm_cta">Продолжить</string>
|
||||
<string name="settings_vk_step_done_title">VK ID обновлён</string>
|
||||
<string name="settings_vk_step_done_body">Аккаунт теперь привязан к VK ID, с которым вы только что вошли.</string>
|
||||
<string name="settings_done">Готово</string>
|
||||
<string name="settings_account_delete_confirm_title">Удалить аккаунт?</string>
|
||||
<string name="settings_account_delete_confirm_body">Это нельзя отменить.</string>
|
||||
|
||||
@@ -62,14 +62,12 @@
|
||||
<string name="auth_step_yandex_title">Sign in with Yandex ID</string>
|
||||
<string name="auth_step_yandex_body">This helps us fight malicious bots and meet Russian legal requirements. From Yandex we only get your email — and we don’t store it; sign-in is only used for security reasons.</string>
|
||||
<string name="auth_step_yandex_cta">Continue with Yandex ID</string>
|
||||
<string name="auth_step_verify_title">Verify your account</string>
|
||||
<string name="auth_step_verify_body">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.</string>
|
||||
<string name="auth_step_vk_cta">Continue with VK ID</string>
|
||||
<string name="auth_captcha_title">Quick check</string>
|
||||
<string name="auth_captcha_body">Confirm you’re human to continue creating your account.</string>
|
||||
<string name="auth_captcha_failed">Captcha verification failed. Please try again.</string>
|
||||
<string name="auth_yandex_webview_title">Yandex ID</string>
|
||||
<string name="auth_yandex_client_mismatch">This server returned an unexpected Yandex app id. Update the app or contact support.</string>
|
||||
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
|
||||
<string name="auth_vk_client_mismatch">This server returned an unexpected VK app id. Update the app or contact support.</string>
|
||||
<string name="auth_vk_failed">VK sign-in was cancelled or failed.</string>
|
||||
<!-- Main Screen -->
|
||||
<string name="chats">Chats</string>
|
||||
<string name="contacts">Contacts</string>
|
||||
@@ -466,13 +464,6 @@
|
||||
<string name="settings_yandex_step_confirm_cta">Continue</string>
|
||||
<string name="settings_yandex_step_done_title">Yandex ID updated</string>
|
||||
<string name="settings_yandex_step_done_body">Your account is now linked to the Yandex ID you just signed in with.</string>
|
||||
<string name="settings_account_change_vk">Change VK ID</string>
|
||||
<string name="settings_account_change_vk_d">Link a different VK account</string>
|
||||
<string name="settings_vk_step_confirm_title">Change VK ID?</string>
|
||||
<string name="settings_vk_step_confirm_body">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.</string>
|
||||
<string name="settings_vk_step_confirm_cta">Continue</string>
|
||||
<string name="settings_vk_step_done_title">VK ID updated</string>
|
||||
<string name="settings_vk_step_done_body">Your account is now linked to the VK ID you just signed in with.</string>
|
||||
<string name="settings_done">Done</string>
|
||||
<string name="settings_account_delete_confirm_title">Delete account?</string>
|
||||
<string name="settings_account_delete_confirm_body">This cannot be undone.</string>
|
||||
|
||||
@@ -107,19 +107,14 @@ 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.SmartCaptchaParams
|
||||
import ru.fromchat.api.schema.user.auth.YandexExchangeRequest
|
||||
import ru.fromchat.api.schema.user.auth.YandexExchangeResponse
|
||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||
@@ -499,9 +494,10 @@ object ApiClient {
|
||||
sealed interface AuthPasswordStepOutcome {
|
||||
data class LoggedIn(val response: LoginResponse) : AuthPasswordStepOutcome
|
||||
data class NeedsRegister(
|
||||
val verificationRequired: Boolean,
|
||||
val yandexRequired: Boolean,
|
||||
val yandex: YandexOAuthParams?,
|
||||
val vk: VkOAuthParams?,
|
||||
val captchaRequired: Boolean,
|
||||
val captcha: SmartCaptchaParams?,
|
||||
) : AuthPasswordStepOutcome
|
||||
}
|
||||
|
||||
@@ -519,11 +515,26 @@ object ApiClient {
|
||||
.body<JsonObject>()
|
||||
val status = raw["status"]?.jsonPrimitive?.contentOrNull
|
||||
return when (status) {
|
||||
"needs_register" -> AuthPasswordStepOutcome.NeedsRegister(
|
||||
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) },
|
||||
)
|
||||
"needs_register" -> {
|
||||
val yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true
|
||||
val captchaRequired = raw["captcha_required"]?.jsonPrimitive?.booleanOrNull == true
|
||||
val captcha = raw["captcha"]?.let {
|
||||
json.decodeFromJsonElement(SmartCaptchaParams.serializer(), it)
|
||||
}
|
||||
ru.fromchat.Logger.i(
|
||||
"SmartCaptcha",
|
||||
"authPasswordStep needs_register yandexRequired=$yandexRequired " +
|
||||
"captchaRequired=$captchaRequired " +
|
||||
"hasCaptchaObject=${captcha != null} " +
|
||||
"clientKeyLen=${captcha?.client_key?.length ?: 0}",
|
||||
)
|
||||
AuthPasswordStepOutcome.NeedsRegister(
|
||||
yandexRequired = yandexRequired,
|
||||
yandex = raw["yandex"]?.let { json.decodeFromJsonElement(YandexOAuthParams.serializer(), it) },
|
||||
captchaRequired = captchaRequired,
|
||||
captcha = captcha,
|
||||
)
|
||||
}
|
||||
else -> AuthPasswordStepOutcome.LoggedIn(json.decodeFromJsonElement(LoginResponse.serializer(), raw))
|
||||
}
|
||||
}
|
||||
@@ -536,26 +547,6 @@ 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") {
|
||||
@@ -1570,19 +1561,6 @@ 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)
|
||||
|
||||
@@ -28,19 +28,17 @@ data class YandexOAuthParams(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VkOAuthParams(
|
||||
val client_id: String,
|
||||
val redirect_uri: String,
|
||||
val authorize_url: String,
|
||||
val scope: String,
|
||||
data class SmartCaptchaParams(
|
||||
val client_key: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthNeedsRegisterResponse(
|
||||
val status: String,
|
||||
val verification_required: Boolean = false,
|
||||
val yandex_required: Boolean = false,
|
||||
val yandex: YandexOAuthParams? = null,
|
||||
val vk: VkOAuthParams? = null,
|
||||
val captcha_required: Boolean = false,
|
||||
val captcha: SmartCaptchaParams? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -54,19 +52,6 @@ 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,
|
||||
@@ -84,23 +69,6 @@ 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,
|
||||
@@ -109,5 +77,5 @@ data class RegisterConfirmRequest(
|
||||
val confirm_password: String,
|
||||
val bio: String? = null,
|
||||
val registration_proof: String? = null,
|
||||
val vk_registration_proof: String? = null,
|
||||
val captcha_token: String? = null,
|
||||
)
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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",
|
||||
)
|
||||
@@ -1,150 +0,0 @@
|
||||
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<Byte>()
|
||||
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()
|
||||
@@ -1,44 +0,0 @@
|
||||
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",
|
||||
)
|
||||
@@ -79,8 +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.captcha.SmartCaptchaNav
|
||||
import ru.fromchat.ui.auth.captcha.SmartCaptchaScreen
|
||||
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
|
||||
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
|
||||
import ru.fromchat.ui.calls.CallOverlay
|
||||
@@ -105,9 +105,6 @@ 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
|
||||
@@ -471,8 +468,8 @@ fun App(
|
||||
YandexOAuthScreen()
|
||||
}
|
||||
|
||||
composable(VkOAuthNav.ROUTE) {
|
||||
VkOAuthScreen()
|
||||
composable(SmartCaptchaNav.ROUTE) {
|
||||
SmartCaptchaScreen()
|
||||
}
|
||||
|
||||
composable("chat") {
|
||||
@@ -708,26 +705,6 @@ 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() },
|
||||
@@ -738,7 +715,6 @@ fun App(
|
||||
},
|
||||
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) },
|
||||
onChangeYandexId = { navController.navigate(SettingsRoutes.AccountYandexFlow) },
|
||||
onChangeVkId = { navController.navigate(SettingsRoutes.AccountVkFlow) },
|
||||
onDeleteAccount = { navController.navigate(SettingsRoutes.AccountDeleteFlow) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package ru.fromchat.ui.auth
|
||||
|
||||
import ru.fromchat.api.schema.user.auth.VkOAuthParams
|
||||
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
|
||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||
|
||||
/**
|
||||
* Survives [AuthScreen] leaving composition when navigating to an OAuth route.
|
||||
* Survives [AuthScreen] leaving composition when navigating to Yandex OAuth / SmartCaptcha routes.
|
||||
* Cleared on welcome / successful auth / explicit reset to username.
|
||||
*/
|
||||
internal object AuthRegisterDraft {
|
||||
@@ -13,11 +13,12 @@ internal object AuthRegisterDraft {
|
||||
var confirmPassword: String = ""
|
||||
var displayName: String = ""
|
||||
var bio: String = ""
|
||||
var verificationRequired: Boolean = false
|
||||
var yandexRequired: Boolean = false
|
||||
var yandexParams: YandexOAuthParams? = null
|
||||
var vkParams: VkOAuthParams? = null
|
||||
var yandexRegistrationProof: String? = null
|
||||
var vkRegistrationProof: String? = null
|
||||
var captchaRequired: Boolean = false
|
||||
var captchaParams: SmartCaptchaParams? = null
|
||||
var registrationProof: String? = null
|
||||
var captchaToken: String? = null
|
||||
var page: Int = 0
|
||||
|
||||
fun clear() {
|
||||
@@ -26,11 +27,12 @@ internal object AuthRegisterDraft {
|
||||
confirmPassword = ""
|
||||
displayName = ""
|
||||
bio = ""
|
||||
verificationRequired = false
|
||||
yandexRequired = false
|
||||
yandexParams = null
|
||||
vkParams = null
|
||||
yandexRegistrationProof = null
|
||||
vkRegistrationProof = null
|
||||
captchaRequired = false
|
||||
captchaParams = null
|
||||
registrationProof = null
|
||||
captchaToken = null
|
||||
page = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,23 +20,27 @@ import io.ktor.client.plugins.ClientRequestException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.crypto.IdentityKeyManager
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.db.clearAccountCacheOnLogout
|
||||
import ru.fromchat.api.instance.ServerProbeResult
|
||||
import ru.fromchat.api.instance.probeServer
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.db.clearAccountCacheOnLogout
|
||||
import ru.fromchat.api.schema.core.ErrorResponse
|
||||
import ru.fromchat.api.schema.user.auth.LoginResponse
|
||||
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
||||
import ru.fromchat.api.schema.user.auth.VkOAuthParams
|
||||
import ru.fromchat.api.schema.user.auth.SmartCaptchaParams
|
||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||
import ru.fromchat.change_server
|
||||
import ru.fromchat.config.Settings
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.auth.captcha.SmartCaptchaLog
|
||||
import ru.fromchat.ui.auth.captcha.SmartCaptchaNav
|
||||
import ru.fromchat.ui.auth.register.confirmPasswordStepPage
|
||||
import ru.fromchat.ui.auth.register.profileStepPage
|
||||
import ru.fromchat.ui.auth.yandex.yandexIdStepPage
|
||||
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.components.TextCta
|
||||
@@ -49,16 +53,17 @@ private enum class AuthFlowStep {
|
||||
Username,
|
||||
Password,
|
||||
ConfirmPassword,
|
||||
IdentityVerify,
|
||||
YandexId,
|
||||
Profile,
|
||||
}
|
||||
|
||||
internal sealed interface PasswordStepResult {
|
||||
data object LoginSuccess : PasswordStepResult
|
||||
data class NeedsRegister(
|
||||
val verificationRequired: Boolean,
|
||||
val yandexRequired: Boolean,
|
||||
val yandex: YandexOAuthParams?,
|
||||
val vk: VkOAuthParams?,
|
||||
val captchaRequired: Boolean,
|
||||
val captcha: SmartCaptchaParams?,
|
||||
) : PasswordStepResult
|
||||
data class WrongPassword(val message: String) : PasswordStepResult
|
||||
data class RateLimited(val message: String) : PasswordStepResult
|
||||
@@ -121,11 +126,20 @@ internal suspend fun authPasswordStep(
|
||||
PasswordStepResult.LoginSuccess
|
||||
}
|
||||
|
||||
is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> PasswordStepResult.NeedsRegister(
|
||||
verificationRequired = outcome.verificationRequired,
|
||||
yandex = outcome.yandex,
|
||||
vk = outcome.vk,
|
||||
)
|
||||
is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"password step needs_register yandexRequired=${outcome.yandexRequired} " +
|
||||
"captchaRequired=${outcome.captchaRequired} " +
|
||||
"clientKey=${SmartCaptchaLog.redactKey(outcome.captcha?.client_key)}",
|
||||
)
|
||||
PasswordStepResult.NeedsRegister(
|
||||
yandexRequired = outcome.yandexRequired,
|
||||
yandex = outcome.yandex,
|
||||
captchaRequired = outcome.captchaRequired,
|
||||
captcha = outcome.captcha,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: ClientRequestException) {
|
||||
when (e.response.status.value) {
|
||||
@@ -148,10 +162,16 @@ internal suspend fun register(
|
||||
displayName: String,
|
||||
password: String,
|
||||
bio: String,
|
||||
yandexRegistrationProof: String?,
|
||||
vkRegistrationProof: String?,
|
||||
registrationProof: String?,
|
||||
captchaToken: String?,
|
||||
unexpectedError: String,
|
||||
) = try {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"register confirm start username=${username.trim()} " +
|
||||
"hasRegistrationProof=${!registrationProof.isNullOrBlank()} " +
|
||||
"captchaToken=${SmartCaptchaLog.redactToken(captchaToken)}",
|
||||
)
|
||||
fullLogin(username.trim(), password.trim()) {
|
||||
val derived = deriveAuthSecret(username.trim(), password.trim())
|
||||
ApiClient.authRegisterConfirm(
|
||||
@@ -161,19 +181,26 @@ internal suspend fun register(
|
||||
password = derived,
|
||||
confirm_password = derived,
|
||||
bio = bio.trim().takeIf { it.isNotEmpty() },
|
||||
registration_proof = yandexRegistrationProof,
|
||||
vk_registration_proof = vkRegistrationProof,
|
||||
registration_proof = registrationProof,
|
||||
captcha_token = captchaToken,
|
||||
),
|
||||
)
|
||||
}
|
||||
Logger.i(SmartCaptchaLog.TAG, "register confirm success username=${username.trim()}")
|
||||
RegisterResult.Success
|
||||
} catch (e: ClientRequestException) {
|
||||
Logger.w(
|
||||
SmartCaptchaLog.TAG,
|
||||
"register confirm HTTP ${e.response.status.value} username=${username.trim()}",
|
||||
e,
|
||||
)
|
||||
if (e.response.status.value == 400 && isUsernameTakenError(e)) {
|
||||
RegisterResult.UsernameTaken
|
||||
} else {
|
||||
RegisterResult.Error(parseClientError(e, unexpectedError))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(SmartCaptchaLog.TAG, "register confirm failed username=${username.trim()}", e)
|
||||
RegisterResult.Error(unexpectedError, e)
|
||||
}
|
||||
|
||||
@@ -218,11 +245,13 @@ fun AuthScreen(
|
||||
var confirmPassword by remember { mutableStateOf(AuthRegisterDraft.confirmPassword) }
|
||||
var displayName by remember { mutableStateOf(AuthRegisterDraft.displayName) }
|
||||
var bio by remember { mutableStateOf(AuthRegisterDraft.bio) }
|
||||
var verificationRequired by remember { mutableStateOf(AuthRegisterDraft.verificationRequired) }
|
||||
var yandexRequired by remember { mutableStateOf(AuthRegisterDraft.yandexRequired) }
|
||||
var yandexParams by remember { mutableStateOf(AuthRegisterDraft.yandexParams) }
|
||||
var vkParams by remember { mutableStateOf(AuthRegisterDraft.vkParams) }
|
||||
var yandexRegistrationProof by remember { mutableStateOf(AuthRegisterDraft.yandexRegistrationProof) }
|
||||
var vkRegistrationProof by remember { mutableStateOf(AuthRegisterDraft.vkRegistrationProof) }
|
||||
var captchaRequired by remember { mutableStateOf(AuthRegisterDraft.captchaRequired) }
|
||||
var captchaParams by remember { mutableStateOf(AuthRegisterDraft.captchaParams) }
|
||||
var registrationProof by remember { mutableStateOf(AuthRegisterDraft.registrationProof) }
|
||||
var captchaToken by remember { mutableStateOf(AuthRegisterDraft.captchaToken) }
|
||||
val navController = LocalNavController.current
|
||||
|
||||
fun persistDraft() {
|
||||
AuthRegisterDraft.username = username
|
||||
@@ -230,11 +259,12 @@ fun AuthScreen(
|
||||
AuthRegisterDraft.confirmPassword = confirmPassword
|
||||
AuthRegisterDraft.displayName = displayName
|
||||
AuthRegisterDraft.bio = bio
|
||||
AuthRegisterDraft.verificationRequired = verificationRequired
|
||||
AuthRegisterDraft.yandexRequired = yandexRequired
|
||||
AuthRegisterDraft.yandexParams = yandexParams
|
||||
AuthRegisterDraft.vkParams = vkParams
|
||||
AuthRegisterDraft.yandexRegistrationProof = yandexRegistrationProof
|
||||
AuthRegisterDraft.vkRegistrationProof = vkRegistrationProof
|
||||
AuthRegisterDraft.captchaRequired = captchaRequired
|
||||
AuthRegisterDraft.captchaParams = captchaParams
|
||||
AuthRegisterDraft.registrationProof = registrationProof
|
||||
AuthRegisterDraft.captchaToken = captchaToken
|
||||
AuthRegisterDraft.page = flowState.pagerState.currentPage
|
||||
}
|
||||
|
||||
@@ -263,11 +293,12 @@ fun AuthScreen(
|
||||
confirmPassword = ""
|
||||
displayName = ""
|
||||
bio = ""
|
||||
verificationRequired = false
|
||||
yandexRequired = false
|
||||
yandexParams = null
|
||||
vkParams = null
|
||||
yandexRegistrationProof = null
|
||||
vkRegistrationProof = null
|
||||
captchaRequired = false
|
||||
captchaParams = null
|
||||
registrationProof = null
|
||||
captchaToken = null
|
||||
AuthRegisterDraft.clear()
|
||||
flowState.resetPredictiveState()
|
||||
scope.launch {
|
||||
@@ -275,11 +306,6 @@ fun AuthScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun clearProofs() {
|
||||
yandexRegistrationProof = null
|
||||
vkRegistrationProof = null
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { persistDraft() }
|
||||
}
|
||||
@@ -290,11 +316,12 @@ fun AuthScreen(
|
||||
confirmPassword,
|
||||
displayName,
|
||||
bio,
|
||||
verificationRequired,
|
||||
yandexRequired,
|
||||
yandexParams,
|
||||
vkParams,
|
||||
yandexRegistrationProof,
|
||||
vkRegistrationProof,
|
||||
captchaRequired,
|
||||
captchaParams,
|
||||
registrationProof,
|
||||
captchaToken,
|
||||
) {
|
||||
persistDraft()
|
||||
}
|
||||
@@ -310,7 +337,12 @@ fun AuthScreen(
|
||||
snapshotFlow { flowState.pagerState.currentPage }
|
||||
.collect { page ->
|
||||
AuthRegisterDraft.page = page
|
||||
if (page == AuthFlowStep.IdentityVerify.ordinal && !verificationRequired) {
|
||||
// Don't skip the Yandex slot mid predictive-back — that fights pager morph
|
||||
// (Profile ↔ ConfirmPassword) and glitches when the gesture is cancelled.
|
||||
if (page == AuthFlowStep.YandexId.ordinal &&
|
||||
!yandexRequired &&
|
||||
flowState.predictiveFromPage == null
|
||||
) {
|
||||
val target = if (page > settledPage) {
|
||||
AuthFlowStep.Profile.ordinal
|
||||
} else {
|
||||
@@ -322,27 +354,85 @@ fun AuthScreen(
|
||||
}
|
||||
if (page < settledPage) {
|
||||
when (page) {
|
||||
AuthFlowStep.Username.ordinal,
|
||||
AuthFlowStep.Password.ordinal,
|
||||
-> {
|
||||
AuthFlowStep.Username.ordinal -> {
|
||||
password = ""
|
||||
confirmPassword = ""
|
||||
verificationRequired = false
|
||||
yandexRequired = false
|
||||
yandexParams = null
|
||||
vkParams = null
|
||||
clearProofs()
|
||||
captchaRequired = false
|
||||
captchaParams = null
|
||||
registrationProof = null
|
||||
captchaToken = null
|
||||
}
|
||||
|
||||
AuthFlowStep.ConfirmPassword.ordinal,
|
||||
AuthFlowStep.IdentityVerify.ordinal,
|
||||
-> clearProofs()
|
||||
AuthFlowStep.Password.ordinal -> {
|
||||
password = ""
|
||||
confirmPassword = ""
|
||||
yandexRequired = false
|
||||
yandexParams = null
|
||||
captchaRequired = false
|
||||
captchaParams = null
|
||||
registrationProof = null
|
||||
captchaToken = null
|
||||
}
|
||||
|
||||
AuthFlowStep.ConfirmPassword.ordinal -> {
|
||||
// Keep confirm password when returning from Yandex ID / OAuth / captcha.
|
||||
registrationProof = null
|
||||
captchaToken = null
|
||||
}
|
||||
|
||||
AuthFlowStep.YandexId.ordinal -> {
|
||||
registrationProof = null
|
||||
}
|
||||
}
|
||||
}
|
||||
settledPage = page
|
||||
}
|
||||
}
|
||||
|
||||
val showVerify = verificationRequired && (yandexParams != null || vkParams != null)
|
||||
// After predictive back settles on the skipped Yandex slot, jump to ConfirmPassword.
|
||||
LaunchedEffect(flowState.predictiveFromPage, yandexRequired) {
|
||||
if (flowState.predictiveFromPage != null || yandexRequired) return@LaunchedEffect
|
||||
if (flowState.pagerState.currentPage == AuthFlowStep.YandexId.ordinal) {
|
||||
flowState.pagerState.scrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(navController) {
|
||||
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||
handle.getStateFlow<String?>(SmartCaptchaNav.RESULT_TOKEN, null).collect { token ->
|
||||
if (token == null) return@collect
|
||||
handle.remove<String>(SmartCaptchaNav.RESULT_TOKEN)
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"AuthScreen received token ${SmartCaptchaLog.redactToken(token)} → Profile",
|
||||
)
|
||||
captchaToken = token
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(navController) {
|
||||
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||
handle.getStateFlow<String?>(SmartCaptchaNav.RESULT_ERROR, null).collect { message ->
|
||||
if (message == null) return@collect
|
||||
handle.remove<String>(SmartCaptchaNav.RESULT_ERROR)
|
||||
Logger.w(SmartCaptchaLog.TAG, "AuthScreen captcha error: $message")
|
||||
snackbar(message)
|
||||
}
|
||||
}
|
||||
|
||||
fun openCaptchaRoute(clientKey: String) {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"navigate ${SmartCaptchaNav.ROUTE} clientKey=${SmartCaptchaLog.redactKey(clientKey)}",
|
||||
)
|
||||
SmartCaptchaNav.pending = SmartCaptchaNav.Session(clientKey = clientKey)
|
||||
navController.navigate(SmartCaptchaNav.ROUTE)
|
||||
}
|
||||
|
||||
val yandexStep = yandexParams
|
||||
ExpressiveStepFlowScaffold(
|
||||
flowState = flowState,
|
||||
pages = listOf(
|
||||
@@ -359,11 +449,18 @@ fun AuthScreen(
|
||||
password = password,
|
||||
onPasswordChange = { password = it },
|
||||
onLoginSuccess = wrappedAuthSuccess,
|
||||
onNeedsRegister = { required, yandex, vk ->
|
||||
verificationRequired = required
|
||||
yandexParams = yandex
|
||||
vkParams = vk
|
||||
clearProofs()
|
||||
onNeedsRegister = { required, params, captchaReq, captcha ->
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"onNeedsRegister yandexRequired=$required captchaRequired=$captchaReq " +
|
||||
"clientKey=${SmartCaptchaLog.redactKey(captcha?.client_key)}",
|
||||
)
|
||||
yandexRequired = required
|
||||
yandexParams = params
|
||||
captchaRequired = captchaReq
|
||||
captchaParams = captcha
|
||||
registrationProof = null
|
||||
captchaToken = null
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
@@ -373,29 +470,32 @@ fun AuthScreen(
|
||||
onConfirmPasswordChange = { confirmPassword = it },
|
||||
password = password,
|
||||
onContinue = {
|
||||
if (showVerify) {
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.IdentityVerify.ordinal)
|
||||
} else {
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
||||
when {
|
||||
yandexRequired && yandexParams != null -> {
|
||||
Logger.i(SmartCaptchaLog.TAG, "confirm → YandexId (captcha skipped)")
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
|
||||
}
|
||||
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
|
||||
openCaptchaRoute(captchaKey)
|
||||
}
|
||||
else -> {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"confirm → Profile captchaRequired=$captchaRequired " +
|
||||
"hasToken=${!captchaToken.isNullOrBlank()}",
|
||||
)
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
),
|
||||
if (showVerify) {
|
||||
identityVerifyStepPage(
|
||||
yandex = yandexParams,
|
||||
vk = vkParams,
|
||||
onProof = { provider, proof ->
|
||||
when (provider) {
|
||||
IdentityProvider.Yandex -> {
|
||||
yandexRegistrationProof = proof
|
||||
vkRegistrationProof = null
|
||||
}
|
||||
IdentityProvider.Vk -> {
|
||||
vkRegistrationProof = proof
|
||||
yandexRegistrationProof = null
|
||||
}
|
||||
}
|
||||
if (yandexStep != null) {
|
||||
yandexIdStepPage(
|
||||
yandex = yandexStep,
|
||||
onProof = { proof ->
|
||||
registrationProof = proof
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
@@ -406,7 +506,16 @@ fun AuthScreen(
|
||||
onConfirmPasswordChange = { confirmPassword = it },
|
||||
password = password,
|
||||
onContinue = {
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
|
||||
when {
|
||||
captchaRequired && captchaToken.isNullOrBlank() && captchaKey.isNotEmpty() -> {
|
||||
openCaptchaRoute(captchaKey)
|
||||
}
|
||||
else -> {
|
||||
Logger.i(SmartCaptchaLog.TAG, "yandex-placeholder confirm → Profile")
|
||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSnackbar = ::snackbar,
|
||||
)
|
||||
@@ -418,8 +527,8 @@ fun AuthScreen(
|
||||
bio = bio,
|
||||
onBioChange = { bio = it },
|
||||
password = password,
|
||||
yandexRegistrationProof = yandexRegistrationProof,
|
||||
vkRegistrationProof = vkRegistrationProof,
|
||||
registrationProof = registrationProof,
|
||||
captchaToken = captchaToken,
|
||||
onRegisterSuccess = wrappedAuthSuccess,
|
||||
onUsernameTaken = resetToUsername,
|
||||
onSnackbar = ::snackbar,
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
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<String?>(YandexOAuthNav.RESULT_PROOF, null).collect { proof ->
|
||||
if (proof == null) return@collect
|
||||
handle.remove<String>(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<String?>(YandexOAuthNav.RESULT_ERROR, null).collect { message ->
|
||||
if (message == null) return@collect
|
||||
handle.remove<String>(YandexOAuthNav.RESULT_ERROR)
|
||||
onSnackbarState.value(message, null)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(navController) {
|
||||
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||
handle.getStateFlow<String?>(VkOAuthNav.RESULT_PROOF, null).collect { proof ->
|
||||
if (proof == null) return@collect
|
||||
handle.remove<String>(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<String?>(VkOAuthNav.RESULT_ERROR, null).collect { message ->
|
||||
if (message == null) return@collect
|
||||
handle.remove<String>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -35,7 +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.SmartCaptchaParams
|
||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||
@@ -56,9 +56,10 @@ internal fun passwordStepPage(
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onLoginSuccess: () -> Unit,
|
||||
onNeedsRegister: suspend (
|
||||
verificationRequired: Boolean,
|
||||
yandexRequired: Boolean,
|
||||
yandex: YandexOAuthParams?,
|
||||
vk: VkOAuthParams?,
|
||||
captchaRequired: Boolean,
|
||||
captcha: SmartCaptchaParams?,
|
||||
) -> Unit,
|
||||
onSnackbar: (String, Throwable?) -> Unit,
|
||||
): ExpressiveStepPage {
|
||||
@@ -141,9 +142,10 @@ internal fun passwordStepPage(
|
||||
|
||||
is PasswordStepResult.NeedsRegister -> {
|
||||
onNeedsRegister(
|
||||
result.verificationRequired,
|
||||
result.yandexRequired,
|
||||
result.yandex,
|
||||
result.vk,
|
||||
result.captchaRequired,
|
||||
result.captcha,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package ru.fromchat.ui.auth.captcha
|
||||
|
||||
/** Safe summaries for SmartCaptcha logs (never dump full tokens/secrets). */
|
||||
internal object SmartCaptchaLog {
|
||||
const val TAG = "SmartCaptcha"
|
||||
|
||||
fun redactKey(value: String?): String {
|
||||
val v = value?.trim().orEmpty()
|
||||
if (v.isEmpty()) return "(empty)"
|
||||
if (v.length <= 8) return "len=${v.length}"
|
||||
return "len=${v.length} prefix=${v.take(4)}…suffix=${v.takeLast(4)}"
|
||||
}
|
||||
|
||||
fun redactToken(value: String?): String {
|
||||
val v = value?.trim().orEmpty()
|
||||
if (v.isEmpty()) return "(empty)"
|
||||
return "len=${v.length} prefix=${v.take(6)}…"
|
||||
}
|
||||
|
||||
fun shortUrl(url: String?): String {
|
||||
if (url.isNullOrBlank()) return "null"
|
||||
// Drop query sitekey from logs; keep path/host.
|
||||
val q = url.indexOf('?')
|
||||
val base = if (q >= 0) url.substring(0, q) else url
|
||||
return if (base.length <= 120) base else base.take(117) + "..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package ru.fromchat.ui.auth.captcha
|
||||
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
/**
|
||||
* Root [androidx.navigation.NavController] route for SmartCaptcha.
|
||||
* Client key is staged in [pending] before navigate (not embedded in the route).
|
||||
*/
|
||||
internal object SmartCaptchaNav {
|
||||
const val ROUTE = "smartCaptcha"
|
||||
const val RESULT_TOKEN = "smartcaptcha_token"
|
||||
const val RESULT_ERROR = "smartcaptcha_error"
|
||||
|
||||
data class Session(
|
||||
val clientKey: String,
|
||||
)
|
||||
|
||||
val SessionSaver = listSaver<Session?, String>(
|
||||
save = { session ->
|
||||
if (session == null) emptyList()
|
||||
else listOf(session.clientKey)
|
||||
},
|
||||
restore = { saved ->
|
||||
if (saved.isEmpty()) null
|
||||
else Session(clientKey = saved[0])
|
||||
},
|
||||
)
|
||||
|
||||
@Volatile
|
||||
var pending: Session? = null
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package ru.fromchat.ui.auth.captcha
|
||||
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.auth_captcha_failed
|
||||
import ru.fromchat.auth_captcha_title
|
||||
import ru.fromchat.back
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.components.Text
|
||||
|
||||
/**
|
||||
* Full-route SmartCaptcha screen (shown after password confirm when Yandex OAuth is off).
|
||||
* Returns a token / error via [SmartCaptchaNav] saved-state results.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun SmartCaptchaScreen() {
|
||||
val navController = LocalNavController.current
|
||||
var session by rememberSaveable(stateSaver = SmartCaptchaNav.SessionSaver) {
|
||||
mutableStateOf(SmartCaptchaNav.pending)
|
||||
}
|
||||
var pageReady by remember { mutableStateOf(false) }
|
||||
val failedMessage = stringResource(Res.string.auth_captcha_failed)
|
||||
val barColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
val languageTag = Locale.current.toLanguageTag()
|
||||
val screenId = remember { (100000..999999).random().toString(16) }
|
||||
|
||||
DisposableEffect(screenId) {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"route enter id=$screenId sessionNull=${session == null} " +
|
||||
"pendingNull=${SmartCaptchaNav.pending == null} " +
|
||||
"clientKey=${SmartCaptchaLog.redactKey(session?.clientKey)} languageTag=$languageTag",
|
||||
)
|
||||
onDispose {
|
||||
Logger.i(SmartCaptchaLog.TAG, "route dispose id=$screenId pageReady=$pageReady")
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(session) {
|
||||
if (session == null) {
|
||||
Logger.w(SmartCaptchaLog.TAG, "session null → popBackStack id=$screenId")
|
||||
navController.popBackStack()
|
||||
} else {
|
||||
SmartCaptchaNav.pending = session
|
||||
}
|
||||
}
|
||||
|
||||
val active = session ?: return
|
||||
|
||||
fun finishWithToken(token: String) {
|
||||
Logger.i(
|
||||
SmartCaptchaLog.TAG,
|
||||
"finishWithToken id=$screenId ${SmartCaptchaLog.redactToken(token)}",
|
||||
)
|
||||
SmartCaptchaNav.pending = null
|
||||
navController.previousBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set(SmartCaptchaNav.RESULT_TOKEN, token)
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
fun finishWithError(message: String) {
|
||||
Logger.w(SmartCaptchaLog.TAG, "finishWithError id=$screenId message=$message")
|
||||
SmartCaptchaNav.pending = null
|
||||
navController.previousBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set(SmartCaptchaNav.RESULT_ERROR, message)
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
Logger.i(SmartCaptchaLog.TAG, "cancel id=$screenId")
|
||||
SmartCaptchaNav.pending = null
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// Top bar draws into the status bar; bottom nav-bar inset keeps the WebView above it
|
||||
// while [containerColor] still paints the gesture/nav area.
|
||||
contentWindowInsets = WindowInsets.navigationBars,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(Res.string.auth_captcha_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { cancel() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.back),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = barColor,
|
||||
scrolledContainerColor = barColor,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
},
|
||||
containerColor = barColor,
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
SmartCaptchaWebView(
|
||||
sitekey = active.clientKey,
|
||||
languageTag = languageTag,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onToken = { finishWithToken(it) },
|
||||
onReady = {
|
||||
Logger.i(SmartCaptchaLog.TAG, "route pageReady id=$screenId")
|
||||
pageReady = true
|
||||
},
|
||||
onChallengeVisible = {
|
||||
Logger.i(SmartCaptchaLog.TAG, "route challengeVisible id=$screenId")
|
||||
},
|
||||
onChallengeHidden = {
|
||||
Logger.i(SmartCaptchaLog.TAG, "route challengeHidden id=$screenId")
|
||||
},
|
||||
onError = { message ->
|
||||
finishWithError(message.ifBlank { failedMessage })
|
||||
},
|
||||
)
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = !pageReady,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(barColor),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularWavyProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package ru.fromchat.ui.auth.captcha
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* Platform WebView that loads Yandex SmartCaptcha and reports the verification token.
|
||||
*/
|
||||
@Composable
|
||||
expect fun SmartCaptchaWebView(
|
||||
sitekey: String,
|
||||
languageTag: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onToken: (String) -> Unit,
|
||||
onReady: () -> Unit = {},
|
||||
onChallengeVisible: () -> Unit = {},
|
||||
onChallengeHidden: () -> Unit = {},
|
||||
onError: (String) -> Unit = {},
|
||||
)
|
||||
@@ -1,28 +0,0 @@
|
||||
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<String> = emptyList(),
|
||||
onPageBackgroundColor: (Color) -> Unit = {},
|
||||
onHistoryBackAvailabilityChanged: (Boolean) -> Unit = {},
|
||||
onRedirectUrl: (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
)
|
||||
@@ -55,8 +55,8 @@ internal fun profileStepPage(
|
||||
bio: String,
|
||||
onBioChange: (String) -> Unit,
|
||||
password: String,
|
||||
yandexRegistrationProof: String?,
|
||||
vkRegistrationProof: String?,
|
||||
registrationProof: String?,
|
||||
captchaToken: String?,
|
||||
onRegisterSuccess: () -> Unit,
|
||||
onUsernameTaken: () -> Unit,
|
||||
onSnackbar: (String, Throwable?) -> Unit,
|
||||
@@ -149,8 +149,8 @@ internal fun profileStepPage(
|
||||
displayName = displayName.trim(),
|
||||
password = password,
|
||||
bio = bio.trim(),
|
||||
yandexRegistrationProof = yandexRegistrationProof,
|
||||
vkRegistrationProof = vkRegistrationProof,
|
||||
registrationProof = registrationProof,
|
||||
captchaToken = captchaToken,
|
||||
unexpectedError = unexpected,
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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<Session?, String>(
|
||||
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
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
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<ErrorResponse>().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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package ru.fromchat.ui.auth.yandex
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.resources.vectorResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||
import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
|
||||
import ru.fromchat.auth.yandex.buildYandexAuthorizeUrl
|
||||
import ru.fromchat.auth.yandex.generatePkcePair
|
||||
import ru.fromchat.auth.yandex.resolveYandexClientId
|
||||
import ru.fromchat.auth_step_yandex_body
|
||||
import ru.fromchat.auth_step_yandex_cta
|
||||
import ru.fromchat.auth_step_yandex_title
|
||||
import ru.fromchat.auth_yandex_client_mismatch
|
||||
import ru.fromchat.config.Settings
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.ic_yandex
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||
import ru.fromchat.ui.components.ExpressiveStepPageHeader
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.isAppInDarkTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun yandexIdStepPage(
|
||||
yandex: YandexOAuthParams,
|
||||
onProof: suspend (String) -> Unit,
|
||||
onSnackbar: (String, Throwable?) -> Unit,
|
||||
): ExpressiveStepPage {
|
||||
val navController = LocalNavController.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
val languageTag = Locale.current.toLanguageTag()
|
||||
val darkTheme = isAppInDarkTheme()
|
||||
val onProofState = rememberUpdatedState(onProof)
|
||||
val onSnackbarState = rememberUpdatedState(onSnackbar)
|
||||
val yandexIcon = vectorResource(Res.drawable.ic_yandex)
|
||||
|
||||
val title = stringResource(Res.string.auth_step_yandex_title)
|
||||
val body = stringResource(Res.string.auth_step_yandex_body)
|
||||
val cta = stringResource(Res.string.auth_step_yandex_cta)
|
||||
val clientMismatch = stringResource(Res.string.auth_yandex_client_mismatch)
|
||||
val unexpected = stringResource(Res.string.error_unexpected)
|
||||
|
||||
LaunchedEffect(navController) {
|
||||
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||
handle.getStateFlow<String?>(YandexOAuthNav.RESULT_PROOF, null).collect { proof ->
|
||||
if (proof == null) return@collect
|
||||
handle.remove<String>(YandexOAuthNav.RESULT_PROOF)
|
||||
busy = true
|
||||
try {
|
||||
onProofState.value(proof)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(navController) {
|
||||
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||
handle.getStateFlow<String?>(YandexOAuthNav.RESULT_ERROR, null).collect { message ->
|
||||
if (message == null) return@collect
|
||||
handle.remove<String>(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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -2,17 +2,19 @@ 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
|
||||
|
||||
/**
|
||||
* Yandex-specific wrapper around [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.
|
||||
*/
|
||||
@Composable
|
||||
internal fun YandexOAuthWebView(
|
||||
expect fun YandexOAuthWebView(
|
||||
authorizeUrl: String,
|
||||
languageTag: String,
|
||||
darkTheme: Boolean,
|
||||
@@ -23,23 +25,4 @@ internal 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,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -400,13 +400,13 @@ fun ExpressiveStepFlowScaffold(
|
||||
flowState.resetPredictiveState()
|
||||
return@PredictiveBackHandler
|
||||
}
|
||||
// Cancel must always reverse — never commit just because progress crossed the threshold.
|
||||
scope.launch {
|
||||
val commit = lastProgress >= predictiveThreshold
|
||||
finishPredictiveMorph(
|
||||
flowState = flowState,
|
||||
pagerState = pagerState,
|
||||
startProgress = lastProgress,
|
||||
targetProgress = if (commit) 1f else 0f,
|
||||
targetProgress = 0f,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -16,9 +16,6 @@ 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"
|
||||
|
||||
-25
@@ -43,11 +43,8 @@ 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
|
||||
@@ -66,20 +63,16 @@ 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(
|
||||
@@ -140,24 +133,6 @@ 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),
|
||||
|
||||
-161
@@ -1,161 +0,0 @@
|
||||
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<VkOAuthParams?>(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,
|
||||
)
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package ru.fromchat.ui.auth.captcha
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import ru.fromchat.Logger
|
||||
|
||||
@Composable
|
||||
actual fun SmartCaptchaWebView(
|
||||
sitekey: String,
|
||||
languageTag: String,
|
||||
modifier: Modifier,
|
||||
onToken: (String) -> Unit,
|
||||
onReady: () -> Unit,
|
||||
onChallengeVisible: () -> Unit,
|
||||
onChallengeHidden: () -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
LaunchedEffect(sitekey) {
|
||||
Logger.w(
|
||||
SmartCaptchaLog.TAG,
|
||||
"iOS stub: captcha unavailable sitekey=${SmartCaptchaLog.redactKey(sitekey)} " +
|
||||
"languageTag=$languageTag",
|
||||
)
|
||||
onError("Captcha is not available on this platform yet.")
|
||||
}
|
||||
}
|
||||
+4
-7
@@ -1,26 +1,23 @@
|
||||
package ru.fromchat.ui.auth.oauth
|
||||
package ru.fromchat.ui.auth.yandex
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@Composable
|
||||
actual fun OAuthWebView(
|
||||
actual fun YandexOAuthWebView(
|
||||
authorizeUrl: String,
|
||||
languageTag: String,
|
||||
darkTheme: Boolean,
|
||||
fallbackColor: Color,
|
||||
redirectUriPrefix: String,
|
||||
isAuthNavigation: (url: String) -> Boolean,
|
||||
clearCookies: Boolean,
|
||||
themeCookieHosts: List<String>,
|
||||
onPageBackgroundColor: (Color) -> Unit,
|
||||
onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
|
||||
onRedirectUrl: (String) -> Unit,
|
||||
onCode: (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
LaunchedEffect(authorizeUrl) {
|
||||
onError("OAuth sign-in is not available on this platform yet.")
|
||||
onError("Yandex sign-in is not available on this platform yet.")
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -9,8 +9,8 @@ plugins {
|
||||
}
|
||||
|
||||
/** Single source of truth for app version (APK + generated [AppBuildInfo]). */
|
||||
extra["versionName"] = "1.1.3"
|
||||
extra["versionCode"] = 113
|
||||
extra["versionName"] = "1.1.4"
|
||||
extra["versionCode"] = 114
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "9.3.0"
|
||||
agp = "9.3.1"
|
||||
androidx-activityCompose = "1.13.0"
|
||||
androidx-appcompat = "1.7.1"
|
||||
androidx-core-ktx = "1.19.0"
|
||||
|
||||
Reference in New Issue
Block a user