4 Commits

61 changed files with 2801 additions and 264 deletions
+12
View File
@@ -19,6 +19,9 @@
android:usesCleartextTraffic="true"
android:name=".App"
android:networkSecurityConfig="@xml/network_security_config">
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/logo" />
<activity
android:name=".MainActivity"
android:exported="true"
@@ -39,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"
@@ -37,6 +37,7 @@ class App: Application() {
override fun onCreate() {
super.onCreate()
UtilsLibrary.init(this)
ru.fromchat.notifications.ChatNotificationDismissals.install(this)
WebSocketManager.addGlobalMessageHandler { msg ->
GlobalScope.launch(Dispatchers.IO) {
@@ -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)
}
}
@@ -403,7 +403,7 @@ object NotificationHelper {
notify(
SUMMARY_NOTIFICATION_ID,
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.logo_big)
.setSmallIcon(R.drawable.logo)
.setContentTitle(title)
.setContentText(body)
.setStyle(
@@ -506,7 +506,7 @@ object NotificationHelper {
notify(
SUMMARY_NOTIFICATION_ID,
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.logo_big)
.setSmallIcon(R.drawable.logo)
.setStyle(
NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build()
+1
View File
@@ -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 {
@@ -0,0 +1,27 @@
package ru.fromchat.notifications
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import ru.fromchat.Logger
actual object ChatNotificationDismissals {
/** Must match [ru.fromchat.notifications.NotificationHelper] summary id in app:android. */
private const val SUMMARY_NOTIFICATION_ID = 1_000_000
@Volatile
private var appContext: Context? = null
fun install(context: Context) {
appContext = context.applicationContext
}
actual fun dismissAllMessageNotifications() {
val context = appContext ?: return
runCatching {
NotificationManagerCompat.from(context).cancel(SUMMARY_NOTIFICATION_ID)
Logger.d("ChatNotificationDismissals", "Cancelled message notifications")
}.onFailure {
Logger.w("ChatNotificationDismissals", "Failed to cancel notifications: ${it.message}", it)
}
}
}
@@ -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">Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. От Яндекса мы получаем только email — и мы его не сохраняем: вход нужен лишь для защиты.</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. From Yandex we only get your email — and we dont store it; sign-in is only used for security reasons.</string>
<string name="auth_step_yandex_cta">Continue with Yandex ID</string>
<string name="auth_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
@@ -68,6 +73,7 @@ import ru.fromchat.api.schema.calls.LiveKitTokenResponse
import ru.fromchat.api.schema.core.SimpleStatusResponse
import ru.fromchat.api.schema.messages.MarkReadRequest
import ru.fromchat.api.schema.messages.MessagesResponse
import ru.fromchat.api.schema.messages.dm.DmArchiveRequest
import ru.fromchat.api.schema.messages.dm.DmConversation
import ru.fromchat.api.schema.messages.dm.DmConversationsResponse
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
@@ -98,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
@@ -484,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
@@ -558,12 +628,18 @@ object ApiClient {
}
.body()
suspend fun getProfileById(userId: Int): UserProfile =
http
suspend fun getProfileById(userId: Int, force: Boolean = false): UserProfile {
if (!force) {
ProfileCache.get(userId)?.takeIf {
!it.isClientPreviewOnly && ProfileCache.hasFreshFullProfile(userId)
}?.let { return it }
}
return http
.get("${ServerConfig.apiBaseUrl}/user/id/$userId") {
contentType(ContentType.Application.Json)
}
.body()
}
suspend fun getProfileByUsername(username: String): UserProfile =
http
@@ -681,6 +757,13 @@ object ApiClient {
}
}
suspend fun archiveDmConversation(otherUserId: Int, archived: Boolean = true) {
http.post("${ServerConfig.apiBaseUrl}/dm/conversations/$otherUserId/archive") {
contentType(ContentType.Application.Json)
setBody(DmArchiveRequest(archived = archived))
}
}
suspend fun searchUsers(query: String): List<User> {
val trimmed = query.trim()
if (trimmed.length < 2) return emptyList()
@@ -1464,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)
@@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.FlowPreview
import kotlinx.serialization.json.JsonElement
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.ConnectionStateStore
@@ -16,14 +15,14 @@ import ru.fromchat.api.local.db.store.ConnectionStatus
import ru.fromchat.api.local.db.store.MessageCacheStore
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.messages.PublicInboxCoordinator
import ru.fromchat.api.local.messages.parseMessageTimestampMillis
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
/**
* Keeps the chats tab list in sync: DM conversations from the server and the latest public-chat
* message for list previews (without opening each chat first).
* Keeps the chats tab list in sync: DM conversations from the server and public-chat
* previews (cache is filled by the updates pipeline / history rebuild).
*/
@OptIn(FlowPreview::class)
object ChatListSync {
@@ -58,7 +57,14 @@ object ChatListSync {
suspend fun syncFromNetwork() {
if (!canSync()) return
syncDmConversations()
syncPublicChatPreview()
// Preview only — never the sole catch-up path for missed messages.
refreshPublicChatPreviewFromLatest()
}
/** Used by tooLong rebuild before per-chat history fetch. */
suspend fun syncDmConversationsForRebuild() {
if (!canSync()) return
syncDmConversations()
}
private fun canSync(): Boolean {
@@ -78,12 +84,13 @@ object ChatListSync {
}
}
private suspend fun syncPublicChatPreview() {
private suspend fun refreshPublicChatPreviewFromLatest() {
runCatching {
val response = ApiClient.getMessages(limit = 1)
val latest = response.messages.maxByOrNull { message ->
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
} ?: return@runCatching
// Upsert only — does not wipe older cached messages.
MessageRepository.upsertPublicMessage(latest)
}
}
@@ -100,16 +107,14 @@ object ChatListSync {
}
}
"newMessage" -> message.data?.let { element ->
scope.launch { ingestPublicMessage(element) }
scope.launch { PublicInboxCoordinator.processNew(element) }
}
"messageEdited" -> message.data?.let { element ->
scope.launch { PublicInboxCoordinator.processEdited(element) }
}
"messageDeleted" -> message.data?.let { element ->
scope.launch { PublicInboxCoordinator.processDeleted(element) }
}
}
}
private suspend fun ingestPublicMessage(element: JsonElement) {
val newMsg = runCatching {
ApiClient.json.decodeFromJsonElement(Message.serializer(), element)
}.getOrNull() ?: return
ProfileCache.mergePreviewFromPublicMessage(newMsg)
MessageRepository.upsertPublicMessage(newMsg)
}
}
@@ -1,28 +1,40 @@
package ru.fromchat.api
import com.pr0gramm3r101.utils.settings.settings
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.Logger
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.local.db.store.MessageCacheStore
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.messages.DmInboundMessageProcessor
import ru.fromchat.api.local.messages.UpdatesBatchApplier
import ru.fromchat.api.local.messages.parseMessageTimestampMillis
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.websocket.WebSocketCredentials
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.requests.AckUpdatesRequest
import ru.fromchat.api.schema.websocket.requests.GetUpdatesRequest
import ru.fromchat.api.schema.websocket.requests.GetUpdatesResponse
import kotlin.concurrent.Volatile
/**
* Tracks the last seen WebSocket update sequence for the current user and
* persists it between sessions so we can ask the backend for missed updates.
* Per-device update cursor: advance + ack only after batches are applied successfully.
*/
object UpdateSyncManager {
private const val KEY_UPDATES_LAST_SEQ_PREFIX = "updates_last_seq_user_"
private const val HISTORY_PAGE_SIZE = 50
private const val MAX_HISTORY_PAGES = 20
private val _lastSeq = MutableStateFlow(0)
val lastSeq: StateFlow<Int> = _lastSeq.asStateFlow()
@@ -30,6 +42,8 @@ object UpdateSyncManager {
private val _lastMissedCount = MutableStateFlow<Int?>(null)
val lastMissedCount: StateFlow<Int?> = _lastMissedCount.asStateFlow()
private val applyMutex = Mutex()
@Volatile
private var gapDetectionInProgress: Boolean = false
@@ -42,22 +56,15 @@ object UpdateSyncManager {
ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null)
}
@OptIn(DelicateCoroutinesApi::class)
fun onUpdatesBatch(seq: Int) {
if (seq <= 0) return
val currentUserId = ApiClient.user?.id ?: return
val newSeq = seq.coerceAtLeast(_lastSeq.value)
if (newSeq == _lastSeq.value) return
_lastSeq.value = newSeq
ConnectionStateStore.updateSeqAndMissed(lastSeq = newSeq, missedCount = _lastMissedCount.value)
val key = KEY_UPDATES_LAST_SEQ_PREFIX + currentUserId
GlobalScope.launch(Dispatchers.Default) {
runCatching {
settings.putInt(key, newSeq)
}.onFailure {
Logger.w("UpdateSyncManager", "Failed to persist lastSeq=$newSeq for userId=$currentUserId: ${it.message}", it)
/**
* Apply a live or replayed updates envelope, then advance cursor and ack the server.
*/
suspend fun onUpdatesEnvelope(jsonTree: JsonElement) {
applyMutex.withLock {
val seq = UpdatesBatchApplier.applyEnvelope(jsonTree) ?: return@withLock
if (seq > _lastSeq.value) {
persistLastSeq(seq)
sendAck(seq)
}
}
}
@@ -81,9 +88,8 @@ object UpdateSyncManager {
}
/**
* Ask the backend for missed updates between our lastSeq and the current sequence.
* This call is idempotent while in progress and will no-op if there is no active
* WebSocket session or no authenticated user.
* Catch up from [lastSeq]: chunked getUpdates, or tooLong → history rebuild.
* Does not advance the cursor until apply/rebuild succeeds.
*/
suspend fun runGapDetectionIfNeeded() {
if (gapDetectionInProgress) {
@@ -98,52 +104,158 @@ object UpdateSyncManager {
}
gapDetectionInProgress = true
val startSeq = _lastSeq.value
ConnectionStateStore.onUpdating(start = true)
try {
Logger.i("UpdateSyncManager", "Running gap detection from lastSeq=$startSeq")
if (startSeq > 0) {
ConnectionStateStore.onUpdating(start = true)
}
var rounds = 0
while (rounds < 100) {
rounds++
val startSeq = _lastSeq.value
Logger.i("UpdateSyncManager", "Gap detection from lastSeq=$startSeq (round=$rounds)")
val requestMessage = WebSocketMessage(
type = "getUpdates",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = token
),
data = ApiClient.json.encodeToJsonElement(
GetUpdatesRequest.serializer(),
GetUpdatesRequest(lastSeq = startSeq)
)
)
val response = WebSocketManager.request(requestMessage)
val data = response?.data
if (data != null) {
runCatching {
val parsed = ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
val response = requestGetUpdates(token, startSeq) ?: break
Logger.i(
"UpdateSyncManager",
"Gap detection result: status=${parsed.status}, lastSeq=${parsed.lastSeq}, missed=${parsed.missedCount}"
"Gap detection result: status=${response.status}, lastSeq=${response.lastSeq}, " +
"missed=${response.missedCount}, hasMore=${response.hasMore}",
)
onUpdatesBatch(parsed.lastSeq)
updateMissedCount(parsed.missedCount)
}.onFailure {
Logger.w("UpdateSyncManager", "Failed to parse getUpdates response: ${it.message}", it)
}
updateMissedCount(response.missedCount)
when (response.status) {
"tooLong" -> {
val ok = rebuildStateFromHistory()
if (ok) {
persistLastSeq(response.lastSeq)
sendAck(response.lastSeq)
} else {
Logger.d("UpdateSyncManager", "No data returned from getUpdates; treating as no-op")
Logger.w("UpdateSyncManager", "History rebuild failed; leaving lastSeq=$startSeq")
}
break
}
"ok" -> {
if (response.lastSeq > _lastSeq.value && response.missedCount == 0) {
persistLastSeq(response.lastSeq)
sendAck(response.lastSeq)
}
if (!response.hasMore) break
}
else -> {
Logger.w("UpdateSyncManager", "Unknown getUpdates status=${response.status}")
break
}
}
}
} catch (t: Throwable) {
Logger.w("UpdateSyncManager", "Gap detection failed: ${t.message}", t)
} finally {
if (startSeq > 0) {
ConnectionStateStore.onUpdating(start = false)
}
gapDetectionInProgress = false
}
}
}
private suspend fun requestGetUpdates(token: String, lastSeq: Int): GetUpdatesResponse? {
val requestMessage = WebSocketMessage(
type = "getUpdates",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = token,
),
data = ApiClient.json.encodeToJsonElement(
GetUpdatesRequest.serializer(),
GetUpdatesRequest(lastSeq = lastSeq),
),
)
val response = WebSocketManager.request(requestMessage)
val data = response?.data ?: return null
return runCatching {
ApiClient.json.decodeFromJsonElement(GetUpdatesResponse.serializer(), data)
}.onFailure {
Logger.w("UpdateSyncManager", "Failed to parse getUpdates response: ${it.message}", it)
}.getOrNull()
}
private suspend fun sendAck(seq: Int) {
val token = ApiClient.token ?: return
if (seq <= 0) return
runCatching {
WebSocketManager.request(
WebSocketMessage(
type = "ackUpdates",
credentials = WebSocketCredentials(scheme = "Bearer", credentials = token),
data = ApiClient.json.encodeToJsonElement(
AckUpdatesRequest.serializer(),
AckUpdatesRequest(lastSeq = seq),
),
),
)
}.onFailure {
Logger.w("UpdateSyncManager", "ackUpdates failed for seq=$seq: ${it.message}", it)
}
}
private suspend fun persistLastSeq(seq: Int) {
val currentUserId = ApiClient.user?.id ?: return
if (seq <= _lastSeq.value) return
_lastSeq.value = seq
ConnectionStateStore.updateSeqAndMissed(lastSeq = seq, missedCount = _lastMissedCount.value)
withContext(Dispatchers.Default) {
runCatching {
settings.putInt(KEY_UPDATES_LAST_SEQ_PREFIX + currentUserId, seq)
}.onFailure {
Logger.w("UpdateSyncManager", "Failed to persist lastSeq=$seq: ${it.message}", it)
}
}
}
suspend fun rebuildStateFromHistory(): Boolean = withContext(Dispatchers.Default) {
Logger.i("UpdateSyncManager", "Rebuilding local state from history (tooLong)")
runCatching {
ChatListSync.syncDmConversationsForRebuild()
rebuildPublicHistory()
rebuildDmHistories()
true
}.onFailure {
Logger.w("UpdateSyncManager", "rebuildStateFromHistory failed: ${it.message}", it)
}.getOrDefault(false)
}
private suspend fun rebuildPublicHistory() {
val collected = LinkedHashMap<Int, Message>()
var beforeId: Int? = null
repeat(MAX_HISTORY_PAGES) {
val page = ApiClient.getMessages(limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
if (page.messages.isEmpty()) return@repeat
page.messages.forEach { msg ->
ProfileCache.mergePreviewFromPublicMessage(msg)
collected[msg.id] = msg
}
val oldest = page.messages.minByOrNull { it.id } ?: return@repeat
if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
beforeId = oldest.id
}
val ordered = collected.values.sortedBy {
parseMessageTimestampMillis(it.timestamp) ?: 0L
}
MessageRepository.replacePublicMessages(ordered)
}
private suspend fun rebuildDmHistories() {
val conversations = MessageRepository.loadCachedDmConversations()
for (conversation in conversations) {
val otherId = conversation.otherUserId
MessageCacheStore.clearDmMessages(otherId)
var beforeId: Int? = null
repeat(MAX_HISTORY_PAGES) {
val page = ApiClient.getDmHistory(otherId, limit = HISTORY_PAGE_SIZE, beforeId = beforeId)
if (page.messages.isEmpty()) return@repeat
for (envelope in page.messages) {
val element = ApiClient.json.encodeToJsonElement(DmEnvelope.serializer(), envelope)
DmInboundMessageProcessor.processNew(element)
}
val oldest = page.messages.minByOrNull { it.id } ?: return@repeat
if (page.messages.size < HISTORY_PAGE_SIZE) return@repeat
beforeId = oldest.id
}
}
}
}
@@ -38,7 +38,6 @@ import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.websocket.WebSocketCredentials
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
import ru.fromchat.config.ServerConfig
import kotlin.concurrent.Volatile
import kotlin.time.Clock
@@ -234,12 +233,11 @@ object WebSocketManager {
val msg = when (messageType) {
"updates" -> {
// Apply add/edit/delete before advancing; ack happens inside.
runCatching {
val updatesData = json.decodeFromJsonElement(
WebSocketUpdatesData.serializer(), jsonTree)
UpdateSyncManager.onUpdatesBatch(updatesData.seq)
UpdateSyncManager.onUpdatesEnvelope(jsonTree)
}.onFailure {
logW("Failed to decode updates envelope for seq tracking: ${it.message}", it)
logW("Failed to apply updates envelope: ${it.message}", it)
}
WebSocketMessage(
@@ -539,7 +539,7 @@ object MessageCacheStore {
.filter { it.type == "dm" }
localDm.forEach { row ->
val otherId = row.otherUserId?.toInt() ?: return@forEach
if (otherId !in serverOtherUserIds) {
if (otherId !in serverOtherUserIds && row.archived == 0L) {
db.messageDatabaseQueries.deleteConversationById(instanceId, row.id)
}
}
@@ -690,6 +690,7 @@ object MessageCacheStore {
id = convId,
)
}
DmConversationListNotifier.notifyChanged()
}
suspend fun deleteDmConversation(otherUserId: Int) {
@@ -117,6 +117,7 @@ object MessageRepository {
suspend fun markDmConversationRead(otherUserId: Int, upToEnvelopeId: Int? = null) {
runCatching { ApiClient.markDmConversationRead(otherUserId, upToEnvelopeId) }
MessageCacheStore.markDmConversationReadLocally(otherUserId, upToEnvelopeId)
ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
}
suspend fun markDmConversationReadUpTo(otherUserId: Int, upToEnvelopeId: Int) {
@@ -137,6 +138,7 @@ object MessageRepository {
runCatching { ApiClient.markMessagesRead(ids) }
}
MessageCacheStore.markPublicMessagesReadLocally()
ru.fromchat.notifications.ChatNotificationDismissals.dismissAllMessageNotifications()
}
suspend fun archiveDmConversation(otherUserId: Int) =
@@ -20,6 +20,8 @@ import ru.fromchat.api.schema.user.profile.UserProfile
import ru.fromchat.api.schema.user.profile.VerificationStatus
import ru.fromchat.api.local.cache.CacheContext
import kotlin.concurrent.Volatile
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
/**
* Returns true when a profile entry should not expose its `username` field
@@ -56,18 +58,34 @@ object ProfileCache {
@Volatile
private var loadedInstanceId: String = ""
/** Epoch millis when a full (non-preview) profile was last applied from the server. */
@Volatile
private var fullProfileFetchedAtMs: Map<Int, Long> = emptyMap()
private val _revision = MutableStateFlow(0)
val revision: StateFlow<Int> = _revision.asStateFlow()
private val persistMutex = Mutex()
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/** Skip force=false network refetch when a full profile was fetched within this window. */
const val FULL_PROFILE_TTL_MS: Long = 5 * 60 * 1000L
private fun bumpRevision() {
_revision.value++
}
fun get(userId: Int): UserProfile? = profiles[userId]
/** True when a full non-preview profile was fetched recently enough to skip refetch. */
@OptIn(ExperimentalTime::class)
fun hasFreshFullProfile(userId: Int, maxAgeMs: Long = FULL_PROFILE_TTL_MS): Boolean {
val profile = get(userId) ?: return false
if (profile.isClientPreviewOnly) return false
val fetchedAt = fullProfileFetchedAtMs[userId] ?: return false
return (Clock.System.now().toEpochMilliseconds() - fetchedAt) <= maxAgeMs
}
/** Emits whenever this user's cached profile changes (including bio). */
fun observeUser(userId: Int): Flow<UserProfile?> =
revision.map { get(userId) }.distinctUntilChanged()
@@ -173,12 +191,20 @@ object ProfileCache {
* When [force] is false, an existing full (non-preview) cache row is kept so a slow HTTP
* response cannot overwrite a fresher WebSocket update.
*/
@OptIn(ExperimentalTime::class)
fun applyServerProfile(profile: UserProfile, force: Boolean = false) {
if (profile.id <= 0) return
val normalized = profile.copy(isClientPreviewOnly = false)
val nowMs = Clock.System.now().toEpochMilliseconds()
if (!force) {
val existing = get(profile.id)
if (existing != null && !existing.isClientPreviewOnly) {
val patched = existing.copy(
verified = normalized.verified ?: existing.verified,
verificationStatus = normalized.verificationStatus
?: existing.verificationStatus,
)
if (patched != existing) put(patched)
if (existing.bio != normalized.bio) {
ru.fromchat.Logger.d(
"ProfileCache",
@@ -186,6 +212,8 @@ object ProfileCache {
"cachedBio='${existing.bio?.take(48)}' httpBio='${normalized.bio?.take(48)}'",
)
}
// Refresh TTL so force=false callers stop refetching.
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
return
}
}
@@ -195,6 +223,7 @@ object ProfileCache {
"bio='${normalized.bio?.take(48)}'",
)
put(normalized)
fullProfileFetchedAtMs = fullProfileFetchedAtMs + (profile.id to nowMs)
}
fun remove(userId: Int) {
@@ -297,7 +326,14 @@ object ProfileCache {
val uid = message.user_id
if (uid <= 0) return
val existing = get(uid)
if (existing != null && !existing.isClientPreviewOnly) return
if (existing != null && !existing.isClientPreviewOnly) {
val patched = existing.copy(
verified = message.verified ?: existing.verified,
verificationStatus = message.verificationStatus ?: existing.verificationStatus,
)
if (patched != existing) put(patched)
return
}
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
if (uname.isBlank()) return
@@ -375,6 +411,7 @@ object ProfileCache {
} else {
emptyMap()
}
fullProfileFetchedAtMs = emptyMap()
pruneUnusableClientPreviewsLocked()
bumpRevision()
}
@@ -428,6 +465,7 @@ object ProfileCache {
suspend fun clear() {
persistMutex.withLock {
profiles = emptyMap()
fullProfileFetchedAtMs = emptyMap()
loadedInstanceId = ""
bumpRevision()
}
@@ -4,16 +4,21 @@ import ru.fromchat.api.schema.messages.Message
/**
* Chat list order: confirmed messages by time, then outgoing queued (negative id) at the bottom.
* Avoids optimistic rows jumping mid-thread when client clock lags the server.
* Pending rows sort by enqueue sequence (monotonic negative id), not hash or wall-clock.
*/
internal fun sortMessagesForChatDisplay(messages: List<Message>): List<Message> {
if (messages.size <= 1) return messages
val (pending, confirmed) = messages.partition { it.id < 0 }
val comparator = compareBy<Message>(
val confirmedComparator = compareBy<Message>(
{ messageSortEpochMillis(it) },
{ it.id.toLong() },
)
return confirmed.sortedWith(comparator) + pending.sortedWith(comparator)
// Monotonic ids are -1, -2, … so -id ascending = enqueue order.
val pendingComparator = compareBy<Message>(
{ -it.id.toLong() },
{ it.client_message_id.orEmpty() },
)
return confirmed.sortedWith(confirmedComparator) + pending.sortedWith(pendingComparator)
}
internal fun messageSortEpochMillis(message: Message): Long =
@@ -20,8 +20,8 @@ fun nowMessageTimestampIso(): String = Clock.System.now().toString()
* Parse message timestamps from server or client.
*
* - Strings with `Z` / an offset (optimistic client stamps, proper UTC) are true instants.
* - Zone-less ISO from the API is naive server wall time (`datetime.now().isoformat()`),
* interpreted in the device zone so HH:mm matches the user's clock.
* - Zone-less ISO from the API is naive UTC wall time (`datetime.now().isoformat()`),
* interpreted as UTC so local formatting shows the user's clock.
*/
internal fun parseMessageInstant(timestamp: String): Instant? {
val raw = timestamp.trim()
@@ -32,7 +32,7 @@ internal fun parseMessageInstant(timestamp: String): Instant? {
}
val local = parseLocalDateTimeOrNull(normalized) ?: return null
return runCatching {
local.toInstant(TimeZone.currentSystemDefault())
local.toInstant(TimeZone.UTC)
}.getOrNull()
}
@@ -1,13 +1,42 @@
package ru.fromchat.api.local.messages
import kotlinx.coroutines.sync.Mutex
import ru.fromchat.api.schema.messages.Message
import kotlin.math.abs
/** Stable negative row id for an optimistic / outbox [clientMessageId]. */
private val optimisticIdMutex = Mutex()
private var nextOptimisticSeq = 0
private val idByClientMessageId = mutableMapOf<String, Int>()
private inline fun <T> withOptimisticIdLock(block: () -> T): T {
while (!optimisticIdMutex.tryLock()) {
// spin — allocation is rare and short
}
try {
return block()
} finally {
optimisticIdMutex.unlock()
}
}
/**
* Negative row id for an optimistic / outbox [clientMessageId].
* Monotonic enqueue order (-1, -2, ); stable for the same client id within process.
*/
fun optimisticMessageIdForClientMessageId(clientMessageId: String): Int {
val hc = clientMessageId.hashCode()
val absHc = if (hc == Int.MIN_VALUE) Int.MAX_VALUE else abs(hc)
return -(if (absHc == 0) 1 else absHc)
val key = clientMessageId.trim()
if (key.isEmpty()) return nextOptimisticMessageId()
return withOptimisticIdLock {
idByClientMessageId.getOrPut(key) {
nextOptimisticSeq += 1
-nextOptimisticSeq
}
}
}
/** Fresh monotonic negative id when no client message id is available yet. */
fun nextOptimisticMessageId(): Int = withOptimisticIdLock {
nextOptimisticSeq += 1
-nextOptimisticSeq
}
fun Message.isQueuedOutbound(): Boolean = id < 0
@@ -0,0 +1,80 @@
package ru.fromchat.api.local.messages
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.websocket.types.MessageDeletedData
/**
* Global public-chat inbox: persists add/edit/delete into [MessageRepository] even when
* no [ru.fromchat.ui.chat.panels.publicchat.PublicChatPanel] is open.
*/
object PublicInboxCoordinator {
suspend fun processNew(element: JsonElement) = withContext(Dispatchers.Default) {
val message = decodeMessage(element) ?: return@withContext
ProfileCache.mergePreviewFromPublicMessage(message)
val clientId = message.client_message_id?.trim().orEmpty()
val currentUserId = ApiClient.user?.id
if (currentUserId != null && message.user_id == currentUserId) {
if (clientId.isNotEmpty()) {
MessageRepository.confirmPublicMessage(clientId, message)
return@withContext
}
// Server omitted client_message_id — still clear a matching pending row if unique.
val pending = MessageRepository.loadPublicMessages()
.filter { it.user_id == currentUserId && it.id < 0 }
val matchCid = when {
pending.size == 1 -> pending.first().client_message_id?.trim()?.takeIf { it.isNotEmpty() }
else -> {
val content = message.content.trim()
pending.singleOrNull {
it.content.trim() == content ||
(!it.files.isNullOrEmpty() && !message.files.isNullOrEmpty())
}?.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
}
}
if (matchCid != null) {
MessageRepository.confirmPublicMessage(
matchCid,
message.copy(client_message_id = matchCid),
)
return@withContext
}
}
MessageRepository.upsertPublicMessage(message)
}
suspend fun processEdited(element: JsonElement) = withContext(Dispatchers.Default) {
val edited = decodeMessage(element) ?: return@withContext
ProfileCache.mergePreviewFromPublicMessage(edited)
val existing = MessageRepository.loadPublicMessages()
val merged = existing.map { current ->
if (current.id == edited.id) {
edited.copy(reply_to = edited.reply_to ?: current.reply_to)
} else {
current
}
}
if (merged.none { it.id == edited.id }) {
MessageRepository.upsertPublicMessage(edited)
} else {
MessageRepository.replacePublicMessages(merged)
}
}
suspend fun processDeleted(element: JsonElement) = withContext(Dispatchers.Default) {
val deleted = runCatching {
ApiClient.json.decodeFromJsonElement(MessageDeletedData.serializer(), element)
}.getOrNull() ?: return@withContext
MessageRepository.deletePublicMessageById(deleted.message_id)
}
private fun decodeMessage(element: JsonElement): Message? =
runCatching {
ApiClient.json.decodeFromJsonElement(Message.serializer(), element)
}.getOrNull()
}
@@ -0,0 +1,45 @@
package ru.fromchat.api.local.messages
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
/**
* Applies a single updates batch in order (add/edit/delete) before the cursor advances.
*/
object UpdatesBatchApplier {
private val mutex = Mutex()
private val publicTypes = setOf("newMessage", "messageEdited", "messageDeleted")
private val dmTypes = setOf("dmNew", "dmDeleted", "dmEdited")
suspend fun applyEnvelope(data: kotlinx.serialization.json.JsonElement): Int? = mutex.withLock {
val envelope = runCatching {
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
}.getOrNull() ?: return@withLock null
for (update in envelope.updates) {
applyOne(WebSocketMessage(type = update.type, data = update.data))
}
envelope.seq
}
suspend fun applyOne(message: WebSocketMessage) {
when (message.type) {
"updates" -> {
val data = message.data ?: return
applyEnvelope(data)
}
"newMessage" -> message.data?.let { PublicInboxCoordinator.processNew(it) }
"messageEdited" -> message.data?.let { PublicInboxCoordinator.processEdited(it) }
"messageDeleted" -> message.data?.let { PublicInboxCoordinator.processDeleted(it) }
"dmNew", "dmDeleted", "dmEdited" -> DmInboxCoordinator.handleMessage(message)
else -> Unit
}
}
fun isCacheAffecting(type: String): Boolean =
type in publicTypes || type in dmTypes
}
@@ -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,9 @@
package ru.fromchat.api.schema.websocket.requests
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AckUpdatesRequest(
@SerialName("lastSeq") val lastSeq: Int,
)
@@ -7,5 +7,6 @@ import kotlinx.serialization.Serializable
data class GetUpdatesResponse(
val status: String,
@SerialName("lastSeq") val lastSeq: Int,
@SerialName("missedCount") val missedCount: Int
@SerialName("missedCount") val missedCount: Int,
@SerialName("hasMore") val hasMore: Boolean = false,
)
@@ -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()
}
@@ -0,0 +1,6 @@
package ru.fromchat.notifications
/** Platform bridge so shared mark-read can dismiss message notifications. */
expect object ChatNotificationDismissals {
fun dismissAllMessageNotifications()
}
@@ -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
}
PasswordStepResult.LoginSuccess
is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> PasswordStepResult.NeedsRegister(
yandexRequired = outcome.yandexRequired,
yandex = outcome.yandex,
)
}
} 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(
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 = {
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 {
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,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.AttachmentMediaLog
import ru.fromchat.api.local.messages.generateClientMessageId
import ru.fromchat.api.local.messages.nowMessageTimestampIso
import ru.fromchat.api.local.messages.optimisticMessageIdForClientMessageId
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.messages.Message
@@ -611,7 +612,7 @@ abstract class ChatPanel(
val tempId = generateClientMessageId()
val newOptimistic = message.copy(
id = uniqueOptimisticMessageId(),
id = optimisticMessageIdForClientMessageId(tempId),
client_message_id = tempId
)
@@ -698,7 +699,7 @@ abstract class ChatPanel(
)
// Unique negative id avoids duplicate LazyColumn keys and bad merge logic.
val optimistic = tempMessage.copy(id = uniqueOptimisticMessageId())
val optimistic = tempMessage.copy(id = optimisticMessageIdForClientMessageId(tempId))
addMessage(optimistic)
Logger.d(
@@ -743,14 +744,6 @@ abstract class ChatPanel(
}
}
private suspend fun uniqueOptimisticMessageId(): Int = addMessageMutex.withLock {
var id: Int
do {
id = -kotlin.random.Random.nextInt(1, Int.MAX_VALUE)
} while (_state.messages.any { it.id == id })
id
}
/** Persist optimistic row for offline / process death; no-op by default. */
protected open suspend fun persistOptimisticMessage(message: Message) {}
@@ -315,11 +315,20 @@ fun ChatScreen(
visitEnterSyncComplete = true
}
// First non-empty load for this visit: never animate existing history.
// First non-empty load for this visit: seed history without animating.
// Exception: empty → single live/own message should animate (first bubble in chat).
if (!enterAnimationsSeeded || previousFingerprint.isEmpty()) {
val isLiveFirstMessage =
previousCount == 0 &&
messages.size == 1 &&
sizeDelta == 1
if (!isLiveFirstMessage) {
seedWithoutAnimating("seed_first_load")
return@LaunchedEffect
}
enterAnimationsSeeded = true
visitEnterSyncComplete = true
}
previousNewestFingerprint = fingerprint
previousEnterMessageCount = messages.size
@@ -413,6 +422,7 @@ fun ChatScreen(
val saveMessageFile = rememberSaveMessageFile { /* best-effort */ }
val haptic = rememberHapticFeedback()
val navController = LocalNavController.current
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val profileUserId = panelState.profileUserId
@@ -701,6 +711,7 @@ fun ChatScreen(
msg.id == menuMessage.id
}
}
if (liveMessage == null) {
contextMenuState = contextMenuState.copy(isOpen = false, message = null)
return@LaunchedEffect
@@ -1376,7 +1387,9 @@ fun ChatScreen(
ChatTopBar(
hazeState = hazeState,
onBack = { navController.navigateUp() },
onBack = {
runNav { navController.navigateUp() }
},
backContentDescription = stringResource(Res.string.back),
showCallButton = panel.showCallButton() && !isReadOnly,
onCallClick = {
@@ -1391,7 +1404,9 @@ fun ChatScreen(
title = panelState.title,
titleAvatar = panelState.titleAvatar,
profileUserId = profileUserId,
onTitleClick = onTitleClick,
onTitleClick = onTitleClick?.let { click ->
{ runNav(click) }
},
hideTitleBarAvatar = hideTitleBarAvatar,
onAvatarSlotBounds = onAvatarSlotBounds,
sharedTransitionScope = sharedTransitionScope,
@@ -32,8 +32,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
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.setValue
import androidx.compose.ui.Alignment
import androidx.lifecycle.Lifecycle
import androidx.navigation.NavController
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
@@ -68,6 +74,7 @@ import ru.fromchat.ui.profile.StatusBadge
import ru.fromchat.ui.profile.peerIsDeleted
import ru.fromchat.ui.profile.resolveVerificationStatus
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.api.ApiClient
import com.pr0gramm3r101.utils.scaleOnPress
import kotlin.math.PI
@@ -258,8 +265,12 @@ fun ChatTopBarInner(
}
key == "typing" -> {
val me = ApiClient.user?.id
TypingIndicator(
typingUsers = currentTypingUsers.map { it.username },
typingUsers = currentTypingUsers.map { user ->
ProfileCache.get(user.userId)?.visibleDisplayName(me)
?: user.username
},
showUsernames = typingShowsUsernames,
modifier = Modifier.padding(top = 2.dp),
)
@@ -447,3 +458,28 @@ fun rememberChatSurfaceContainerHazeStyle(): HazeStyle {
)
}
}
/**
* Single-flight gate for chat title/avatar navigate vs back during shared-element transitions.
* Ignores taps while a transition is running or the current destination is not resumed.
*/
@Composable
fun rememberChatNavigationGate(
navController: NavController,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
): (() -> Unit) -> Unit {
var locked by remember { mutableStateOf(false) }
val entry = navController.currentBackStackEntry
LaunchedEffect(entry) {
locked = false
}
return { action ->
val transitionRunning = animatedVisibilityScope?.transition?.isRunning == true
val lifecycleOk = navController.currentBackStackEntry?.lifecycle?.currentState
?.let { it == Lifecycle.State.RESUMED } != false
if (!locked && !transitionRunning && lifecycleOk) {
locked = true
action()
}
}
}
@@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
@@ -715,7 +714,7 @@ fun ImageFullscreenPreview(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.systemBars),
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
AnimatedVisibility(
visible = effectiveMenusVisible,
@@ -796,6 +795,9 @@ fun ImageFullscreenPreview(
},
)
}
if (currentUserId != null &&
(message.user_id == currentUserId || currentUserId == 1)
) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -808,20 +810,21 @@ fun ImageFullscreenPreview(
menuExpanded = false
onDelete(message)
dismissRequested = true
}
},
)
}
}
}
}
}
}
// Bottom: message text
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.systemBars),
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
AnimatedVisibility(
visible = effectiveMenusVisible && message.content.isNotBlank(),
@@ -204,7 +204,6 @@ fun MessageItem(
isMessageCorrupted(message)
}
val primaryIsImageMessage = remember(
message.reply_to,
message.pendingFileUri,
message.pendingFilename,
message.files,
@@ -219,10 +218,8 @@ fun MessageItem(
)
else -> false
}
message.reply_to == null && (
pendingIsImage ||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
)
}
val formattedTime = remember(message.timestamp) {
formatMessageTimeLocal(message.timestamp)
@@ -569,10 +566,8 @@ fun MessageItem(
} else {
null
}
val primaryIsImageContent = message.reply_to == null && (
pendingIsImage ||
val primaryIsImageContent = pendingIsImage ||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
)
val hasImageCaption =
message.content.isNotBlank() &&
!isFilenameOnlyMessageCaption(message)
@@ -12,6 +12,7 @@ import ru.fromchat.api.local.cache.CacheContext
import androidx.compose.ui.Modifier
import androidx.navigation.NavController
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.ui.chat.rememberChatNavigationGate
import ru.fromchat.utils.haptic.HapticFeedbackEvent
import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.utils.haptic.rememberHapticFeedback
@@ -46,6 +47,7 @@ fun DmChatRoute(
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
val haptic = rememberHapticFeedback()
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
DmScreen(
@@ -54,8 +56,10 @@ fun DmChatRoute(
modifier = modifier.fillMaxSize(),
scrollToMessageId = scrollToMessageId,
onTitleClick = {
runNav {
haptic(HapticFeedbackEvent.ProfileOpened)
navController.navigate(DmNav.profileRoute(otherUserId))
}
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
@@ -74,6 +78,7 @@ fun DmProfileRoute(
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
val panel = remember(otherUserId, activeInstanceId) { DmPanelCache.getOrCreate(otherUserId) }
val haptic = rememberHapticFeedback()
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val sharedAvatarKey = remember(otherUserId) { "$DM_AVATAR_KEY_PREFIX$otherUserId" }
val stateSnapshot = panel.getState()
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
@@ -92,12 +97,16 @@ fun DmProfileRoute(
userId = otherUserId,
showBackButton = true,
onBack = {
runNav {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
}
},
onChat = {
runNav {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
}
},
modifier = modifier.fillMaxSize(),
sharedTransitionScope = sharedTransitionScope,
@@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier
import androidx.navigation.NavController
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.ui.chat.rememberChatNavigationGate
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
import ru.fromchat.ui.profile.PublicChatProfileScreen
import ru.fromchat.utils.haptic.HapticFeedbackEvent
@@ -31,6 +32,7 @@ fun PublicChatChatRoute(
modifier: Modifier = Modifier,
) {
val haptic = rememberHapticFeedback()
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
PublicChatScreen(
scrollToMessageId = scrollToMessageId,
@@ -38,8 +40,10 @@ fun PublicChatChatRoute(
animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarKey = PublicChatNav.SHARED_HEADER_KEY,
onTitleClick = {
runNav {
haptic(HapticFeedbackEvent.ProfileOpened)
navController.navigate(PublicChatNav.PROFILE_ROUTE)
}
},
modifier = modifier.fillMaxSize(),
)
@@ -57,6 +61,7 @@ fun PublicChatProfileRoute(
PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
}
val haptic = rememberHapticFeedback()
val runNav = rememberChatNavigationGate(navController, animatedVisibilityScope)
val stateSnapshot = panel.getState()
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
?: stateSnapshot.title.takeIf { it.isNotBlank() }
@@ -65,12 +70,16 @@ fun PublicChatProfileRoute(
PublicChatProfileScreen(
showBackButton = true,
onBack = {
runNav {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
}
},
onChat = {
runNav {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
}
},
modifier = modifier.fillMaxSize(),
sharedTransitionScope = sharedTransitionScope,
@@ -236,6 +236,7 @@ class PublicChatPanel(
/**
* Match optimistic rows via [Message.client_message_id] from the server ack (never by text).
* When the server omits client_message_id, fall back to a single pending own optimistic.
*/
private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) {
val laidOut = newMsg.resolvePublicAttachmentLayout()
@@ -247,12 +248,56 @@ class PublicChatPanel(
return
}
if (laidOut.id > 0 && _state.messages.any { it.id == laidOut.id }) {
// Already confirmed in UI — still try to clear a leftover optimistic.
matchPendingOptimisticClientId(laidOut)?.let { pendingCid ->
handleMessageConfirmed(
pendingCid,
laidOut.copy(client_message_id = pendingCid),
)
}
return
}
matchPendingOptimisticClientId(laidOut)?.let { pendingCid ->
handleMessageConfirmed(
pendingCid,
laidOut.copy(client_message_id = pendingCid),
)
return
}
}
ingestIncomingPublicMessage(laidOut)
}
/** Best-effort: unique pending own optimistic that looks like [confirmed]. */
private fun matchPendingOptimisticClientId(confirmed: Message): String? {
val pending = snapshotPendingOptimisticMessages().filter {
it.user_id == confirmed.user_id && it.id < 0
}
if (pending.isEmpty()) return null
val confirmedCid = confirmed.client_message_id?.trim().orEmpty()
if (confirmedCid.isNotEmpty()) {
pending.firstOrNull { it.client_message_id?.trim() == confirmedCid }
?.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
?.let { return it }
}
if (pending.size == 1) {
return pending.first().client_message_id?.trim()?.takeIf { it.isNotEmpty() }
}
val confirmedTime = ru.fromchat.api.local.messages.parseMessageTimestampMillis(confirmed.timestamp)
val content = confirmed.content.trim()
val matches = pending.filter { opt ->
val optCid = opt.client_message_id?.trim().orEmpty()
if (optCid.isEmpty()) return@filter false
if (opt.content.trim() != content && confirmed.files.isNullOrEmpty() && opt.files.isNullOrEmpty()) {
return@filter false
}
val optTime = ru.fromchat.api.local.messages.parseMessageTimestampMillis(opt.timestamp)
confirmedTime == null || optTime == null ||
kotlin.math.abs(confirmedTime - optTime) <= 180_000L
}
return matches.singleOrNull()?.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
}
private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
ProfileCache.mergePreviewFromPublicMessage(newMsg)
val withReply = attachPublicReplyReferences(_state.messages + newMsg).last()
@@ -35,10 +35,10 @@ internal fun messageDedupeKey(msg: Message): String {
/**
* Drops optimistic rows already represented by a confirmed message (same client id),
* or legacy near-duplicate own rows that have no client id.
* or near-duplicate own rows when the confirmed ack omitted client_message_id.
*
* In-flight sends with a [Message.client_message_id] that is not yet confirmed must be kept
* time-based heuristics must not remove them (that aborted enter animations mid-spring).
* In-flight sends with a [Message.client_message_id] that is not yet confirmed must be kept
* unless a confirmed own row without client id is a clear near-duplicate (1:1 pairing).
*/
internal fun dropSupersededOptimisticMessages(
messages: List<Message>,
@@ -48,17 +48,27 @@ internal fun dropSupersededOptimisticMessages(
val confirmed = messages.filter { it.id > 0 }
val confirmedClientIds = confirmed.mapNotNull { it.client_message_id?.trim()?.takeIf { it.isNotEmpty() } }.toSet()
val self = currentUserId
val usedConfirmedIds = mutableSetOf<Int>()
return messages.filter { msg ->
if (msg.id >= 0) return@filter true
val cid = msg.client_message_id?.trim().orEmpty()
if (cid.isNotEmpty() && cid in confirmedClientIds) return@filter false
// Stable client id still in flight — never drop via time heuristics.
if (cid.isNotEmpty()) return@filter true
// In-flight uploads (file or image): keep until a confirmed row shares the same client id.
if (msg.pendingFileUri != null || !msg.uploadJobId.isNullOrBlank()) return@filter true
if (self == null || msg.user_id != self) return@filter true
// Match confirmed acks that omitted client_message_id (phantom duplicate).
if (cid.isNotEmpty()) {
val matched = findNearOwnConfirmedWithoutClientId(msg, confirmed, self, usedConfirmedIds)
if (matched != null) {
usedConfirmedIds.add(matched.id)
return@filter false
}
return@filter true
}
// Legacy: no client id on pending — time-based near-dup.
if (msg.pendingFileUri != null || !msg.uploadJobId.isNullOrBlank()) return@filter true
val msgTime = parseMessageTimestampMillis(msg.timestamp)
val nearOwnConfirmed = confirmed.filter { it.user_id == self }.any { confirmedMsg ->
val nearOwnConfirmed = confirmed.filter { it.user_id == self && it.id !in usedConfirmedIds }.any { confirmedMsg ->
val confirmedTime = parseMessageTimestampMillis(confirmedMsg.timestamp)
msgTime != null && confirmedTime != null &&
abs(msgTime - confirmedTime) <= NEAR_DUPLICATE_MS
@@ -77,6 +87,34 @@ internal fun dropSupersededOptimisticMessages(
private const val NEAR_DUPLICATE_MS = 180_000L
private fun findNearOwnConfirmedWithoutClientId(
pending: Message,
confirmed: List<Message>,
self: Int,
usedConfirmedIds: Set<Int>,
): Message? {
val msgTime = parseMessageTimestampMillis(pending.timestamp) ?: return null
val pendingIsAttachment = !pending.files.isNullOrEmpty() ||
pending.pendingFileUri != null ||
!pending.uploadJobId.isNullOrBlank()
val candidates = confirmed.filter { confirmedMsg ->
if (confirmedMsg.user_id != self) return@filter false
if (confirmedMsg.id in usedConfirmedIds) return@filter false
if (!confirmedMsg.client_message_id.isNullOrBlank()) return@filter false
val confirmedTime = parseMessageTimestampMillis(confirmedMsg.timestamp) ?: return@filter false
if (abs(msgTime - confirmedTime) > NEAR_DUPLICATE_MS) return@filter false
val confirmedHasAttachment = !confirmedMsg.files.isNullOrEmpty()
val contentMatches = pending.content.trim() == confirmedMsg.content.trim()
when {
pendingIsAttachment && confirmedHasAttachment -> true
!pendingIsAttachment && !confirmedHasAttachment && contentMatches -> true
!pendingIsAttachment && confirmedHasAttachment && contentMatches -> true
else -> false
}
}
return candidates.minByOrNull { abs((parseMessageTimestampMillis(it.timestamp) ?: 0L) - msgTime) }
}
private fun preferMessageForDedupe(existing: Message, incoming: Message): Message {
val preferred = when {
incoming.id > 0 && existing.id < 0 -> incoming
@@ -28,7 +28,12 @@ internal fun attachPublicReplyReferences(
return messages.map { msg ->
val replyId = parsedReplyIds[msg.id] ?: resolvePublicReplyToId(msg) ?: return@map msg
val nested = msg.reply_to
if (nested != null && nested.content.isNotBlank()) return@map msg
if (
nested != null &&
(nested.content.isNotBlank() || !nested.files.isNullOrEmpty())
) {
return@map msg
}
byId[replyId]?.let { msg.copy(reply_to = it, replyToId = replyId) } ?: msg
}
}
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -247,6 +248,7 @@ fun ChatsSearchScreen(
placeholder = searchBarHint,
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(12.dp),
leadingIcon = {
IconButton(onClick = {
@@ -654,28 +654,12 @@ fun ChatsTab(
fun deleteChats(userIds: Set<Int>) {
scope.launch {
var failures = 0
userIds.forEach { otherUserId ->
var ok = true
val messages = runCatching { MessageRepository.loadDmMessages(otherUserId) }
.getOrDefault(emptyList())
.filter { it.id > 0 }
messages.forEach { msg ->
runCatching {
ApiClient.deleteDm(msg.id, otherUserId)
}.onFailure {
ok = false
ApiClient.archiveDmConversation(otherUserId, archived = true)
MessageRepository.archiveDmConversation(otherUserId)
}
}
runCatching {
MessageRepository.deleteDmConversation(otherUserId)
}.onFailure { ok = false }
if (!ok) failures++
}
refreshDmList()
exitSelectionMode()
@@ -390,7 +390,7 @@ fun DevicesScreen(onBack: () -> Unit) {
}
val hadContent = devices.isNotEmpty()
if (hadContent) refreshing = true
if (!hadContent) refreshing = true
runCatching { ApiClient.listDevices() }
.onSuccess { list ->
@@ -570,11 +570,8 @@ fun DevicesScreen(onBack: () -> Unit) {
}
item {
AnimatedVisibility(
visible = refreshing,
enter = fadeIn(),
exit = fadeOut(),
) {
// Initial load only — background poll must not add/remove list height (overscroll jump).
if (refreshing && devices.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -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"
@@ -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),
@@ -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,
)
}
@@ -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,
)
}
@@ -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
}
}
@@ -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)
}
}
}
}
@@ -18,13 +18,17 @@ fun resolveVerificationStatus(
message: Message? = null,
user: User? = null,
): VerificationStatus? {
ProfileCache.get(userId)?.effectiveVerificationStatus()?.let { cached ->
if (cached != VerificationStatus.None || ProfileCache.get(userId)?.verificationStatus != null) {
return cached
ProfileCache.get(userId)?.let { cached ->
if (cached.verificationStatus != null || cached.verified != null) {
return cached.effectiveVerificationStatus()
}
}
user?.effectiveVerificationStatus()?.let { return it }
user?.let { u ->
if (u.verificationStatus != null || u.verified != null) {
return u.effectiveVerificationStatus()
}
}
message?.verificationStatus?.let { return it }
message?.verified?.let { return if (it) VerificationStatus.Verified else null }
@@ -0,0 +1,5 @@
package ru.fromchat.notifications
actual object ChatNotificationDismissals {
actual fun dismissAllMessageNotifications() = Unit
}
@@ -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.")
}
}
+2
View File
@@ -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" }