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