Fix WebSocket, refactor code

This commit is contained in:
2025-12-20 17:41:42 +03:00
Unverified
parent a59672103b
commit 8151e0ca0d
22 changed files with 185 additions and 147 deletions
+7 -7
View File
@@ -10,7 +10,7 @@ plugins {
kotlin("plugin.serialization") version "1.9.22"
}
kotlin {
kotlin {
androidTarget()
compilerOptions {
@@ -27,14 +27,14 @@ plugins {
isStatic = true
}
}
sourceSets {
all {
languageSettings {
optIn("kotlin.RequiresOptIn")
}
}
androidMain.dependencies {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
@@ -63,21 +63,21 @@ plugins {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.io.core)
// Ktor - force version 2.3.12 to avoid conflicts with Coil 3's Ktor 3
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.client.logging)
// Datetime
implementation(libs.kotlinx.datetime)
// Coil for image loading (multiplatform)
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3)
implementation(project(":utils"))
}
@@ -3,15 +3,5 @@ package ru.fromchat.api
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.okhttp.OkHttp
import okhttp3.Dns
actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit): HttpClient {
return HttpClient(OkHttp) {
engine {
config {
dns(Dns.SYSTEM)
}
}
block()
}
}
actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit) = HttpClient(OkHttp, block)
@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<base-config cleartextTrafficPermitted="true"
tools:ignore="InsecureBaseConfiguration">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
@@ -11,10 +11,4 @@
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
</domain-config>
<domain-config>
<domain includeSubdomains="true">fromchat.ru</domain>
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</domain-config>
</network-security-config>
@@ -41,7 +41,7 @@
<string name="server_config_title">Server Configuration</string>
<string name="server_config_subtitle">Enter server details to connect</string>
<string name="server_url_label">Server URL</string>
<string name="server_url_hint">fromchat.ru</string>
<string name="server_url_hint">example.com</string>
<string name="https_enabled">Use HTTPS</string>
<string name="save_continue">Save &amp; Continue</string>
@@ -8,6 +8,7 @@ import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.logging.SIMPLE
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.pingInterval
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get
import io.ktor.client.request.parameter
@@ -21,6 +22,7 @@ import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.core.config.Config
import ru.fromchat.utils.failOnError
import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.milliseconds
/**
* Creates a platform-specific HTTP client that supports WebSockets
@@ -47,7 +49,9 @@ object ApiClient {
level = LogLevel.INFO
}
install(WebSockets)
install(WebSockets) {
pingInterval = 5000.milliseconds // Send a ping every 5 seconds to keep the connection alive
}
}
@Volatile
@@ -58,7 +62,7 @@ object ApiClient {
suspend fun login(request: LoginRequest) =
http
.post("${Config.getApiBaseUrl()}/login") {
.post("${Config.apiBaseUrl}/login") {
contentType(ContentType.Application.Json)
setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") })
}
@@ -71,7 +75,7 @@ object ApiClient {
suspend fun register(request: RegisterRequest) =
http
.post("${Config.getApiBaseUrl()}/register") {
.post("${Config.apiBaseUrl}/register") {
contentType(ContentType.Application.Json)
setBody(request)
}
@@ -79,7 +83,7 @@ object ApiClient {
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
http
.get("${Config.getApiBaseUrl()}/get_messages") {
.get("${Config.apiBaseUrl}/get_messages") {
contentType(ContentType.Application.Json)
bearerAuth(token ?: throw IllegalStateException("Not authenticated"))
parameter("limit", limit)
@@ -90,7 +94,7 @@ object ApiClient {
suspend fun send(message: String) =
http
.post("${Config.getApiBaseUrl()}/send_message") {
.post("${Config.apiBaseUrl}/send_message") {
contentType(ContentType.Application.Json)
bearerAuth(token!!)
setBody(SendMessageRequest(message))
@@ -102,7 +106,7 @@ object ApiClient {
suspend fun logout(authToken: String) {
// Don't throw on logout errors, just try to logout
try {
http.get("${Config.getApiBaseUrl()}/logout") {
http.get("${Config.apiBaseUrl}/logout") {
bearerAuth(authToken)
}
} catch (e: Exception) {
@@ -37,3 +37,7 @@ data class TypingUpdateData(
val username: String
)
@Serializable
data class WebSocketAuthMessage(
val token: String
)
@@ -47,38 +47,44 @@ object WebSocketManager {
}
// State
@Volatile
private var connecting: Boolean = false
@Volatile private var connecting = false
@Volatile private var session: DefaultClientWebSocketSession? = null
/**
* Check if WebSocket is connected
*/
fun isConnected(): Boolean = session != null
val isConnected get() = session != null
/**
* Wait for WebSocket connection with timeout
*/
@OptIn(ExperimentalTime::class)
suspend fun waitForConnection(timeoutMs: Long = 10000): Boolean {
Logger.d("WebSocketManager", "waitForConnection: session=${session != null}, connecting=$connecting")
if (session != null) return true
val startTime = Clock.System.now().toEpochMilliseconds()
while (session == null && (Clock.System.now().toEpochMilliseconds() - startTime) < timeoutMs) {
delay(100)
}
Logger.d("WebSocketManager", "waitForConnection finished: session=${session != null}")
return session != null
}
fun connect() {
Logger.d("WebSocketManager", "Connecting to WebSocket")
if (connecting) return
Logger.d("WebSocketManager", "connect() called. current session=${session != null}, connecting=$connecting")
if (connecting) {
Logger.d("WebSocketManager", "connect() ignored: already connecting")
return
}
connecting = true
Logger.d("WebSocketManager", "connecting set to true")
scope.launch {
while (isActive) {
Logger.d("WebSocketManager", "Connection loop active. isActive=$isActive")
try {
val wsUrl = Config.getWebSocketUrl()
Logger.d("WebSocketManager", "Connecting to: $wsUrl")
val wsUrl = Config.webSocketUrl
Logger.d("WebSocketManager", "Attempting to connect to: $wsUrl")
ApiClient.http.webSocket(
method = HttpMethod.Get,
request = {
@@ -87,25 +93,38 @@ object WebSocketManager {
) {
session = this
connecting = false
Logger.d("WebSocketManager", "WebSocket connected. connecting set to false")
// Send ping message immediately after connection for authentication
ApiClient.token?.let {
Logger.d("WebSocketManager", "Sending WebSocket ping for authentication")
send(WebSocketMessage(type = "ping", credentials = WebSocketCredentials(scheme = "Bearer", credentials = it)))
}
Logger.d("WebSocketManager", "WebSocket connected")
for (frame in incoming) {
val text = (frame as? Frame.Text)?.readText() ?: continue
Logger.d("WebSocketManager", "Received payload: $text")
try {
// Check if this is an "updates" message - it has a different structure
val jsonTree = json.parseToJsonElement(text)
val messageType = jsonTree.jsonObject["type"]?.jsonPrimitive?.content
val msg = if (messageType == "updates") {
// Wrap in WebSocketMessage with the entire JSON tree as data
WebSocketMessage(
type = "updates",
data = jsonTree
)
} else {
// Parse as regular WebSocketMessage
json.decodeFromString<WebSocketMessage>(text)
val msg = when (messageType) {
"updates" -> {
WebSocketMessage(
type = "updates",
data = jsonTree
)
}
"typing", "stopTyping" -> {
// These messages are expected to be direct, without additional data in the web client
WebSocketMessage(
type = messageType,
data = jsonTree.jsonObject["data"] // Extract data if present
)
}
else -> {
json.decodeFromString<WebSocketMessage>(text)
}
}
globalHandlers.forEach { it(msg) }
@@ -117,12 +136,13 @@ object WebSocketManager {
}
}
} catch (e: Throwable) {
Logger.w("WebSocketManager", "An error occurred: ${e.message}", e)
Logger.w("WebSocketManager", "An error occurred during WebSocket connection: ${e.message}", e)
connecting = false
session = null
Logger.d("WebSocketManager", "Reconnecting in 3 seconds...")
delay(3000)
} finally {
Logger.w("WebSocketManager", "WebSocket disconnected")
Logger.w("WebSocketManager", "WebSocket disconnected. session set to null, connecting set to false")
session = null
connecting = false
}
@@ -190,6 +210,15 @@ object WebSocketManager {
}
fun shutdown() {
Logger.d("WebSocketManager", "shutdown() called. Cancelling scope.")
scope.cancel()
}
}
fun disconnect() {
Logger.d("WebSocketManager", "disconnect() called. current session=${session != null}")
session?.cancel() // Close the WebSocket session
session = null
connecting = false
Logger.d("WebSocketManager", "Disconnected. session set to null, connecting set to false")
}
}
@@ -12,6 +12,9 @@ import kotlinx.coroutines.flow.asStateFlow
object Config {
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
private val config
get() = _serverConfig.value ?: throw IllegalStateException("Server configuration not initialized")
/**
* Initialize configuration by loading from storage
@@ -31,25 +34,17 @@ object Config {
/**
* Get API base URL based on current server configuration
*/
fun getApiBaseUrl(): String {
val config = _serverConfig.value ?: ServerConfigData("fromchat.ru", true)
val protocol = if (config.httpsEnabled) "https" else "http"
return "$protocol://${config.serverUrl}/api"
}
val apiBaseUrl
get() = "${if (config.httpsEnabled) "https" else "http"}://${config.serverUrl}/api"
/**
* Get WebSocket URL based on current server configuration
*/
fun getWebSocketUrl(): String {
val config = _serverConfig.value ?: ServerConfigData("fromchat.ru", true)
val protocol = if (config.httpsEnabled) "wss" else "ws"
return "$protocol://${config.serverUrl}/api/chat/ws"
}
val webSocketUrl
get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws"
/**
* Checks if server configuration exists
*/
suspend fun hasServerConfig(): Boolean {
return ServerConfigStorage.hasConfiguration()
}
suspend fun hasServerConfig() = ServerConfigStorage.hasConfiguration()
}
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
@@ -15,7 +16,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.IntOffset
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@@ -61,6 +66,29 @@ fun App() {
}
}
// Observe lifecycle events to manage WebSocket connection
val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
// Connect WebSocket when app comes to foreground
WebSocketManager.connect()
}
Lifecycle.Event.ON_PAUSE -> {
// Disconnect WebSocket when app goes to background
WebSocketManager.disconnect()
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
FromChatTheme {
val navController = rememberNavController()
val animationSpec = tween<IntOffset>(400)
@@ -111,8 +139,6 @@ fun App() {
composable("login") {
LoginScreen(
onLoginSuccess = {
// Ensure WebSocket is connected after login
WebSocketManager.connect()
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
@@ -45,7 +45,7 @@ fun Avatar(
val fullUrl = if (profilePictureUrl.startsWith("http")) {
profilePictureUrl
} else {
"${Config.getApiBaseUrl()}$profilePictureUrl"
"${Config.apiBaseUrl}$profilePictureUrl"
}
AsyncImage(
@@ -22,7 +22,8 @@ data class ChatPanelState(
val messages: List<Message> = emptyList(),
val isLoading: Boolean = false,
val hasMoreMessages: Boolean = false,
val isLoadingMore: Boolean = false
val isLoadingMore: Boolean = false,
val typingUsers: List<TypingUser> = emptyList()
)
/**
@@ -61,9 +61,9 @@ import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.Message
import ru.fromchat.api.TypingData
import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back
import ru.fromchat.core.Logger
import ru.fromchat.ui.LocalNavController
@@ -80,10 +80,10 @@ fun ChatScreen(
// Observe state changes
LaunchedEffect(panel) {
panel.setOnStateChange { newState ->
Logger.d("ChatScreen", "State change callback received: messages=${newState.messages.size}")
Logger.d("ChatScreen", "State change callback received: messages=${newState.messages.size}, typingUsers=${newState.typingUsers.map { it.username }}")
// Force state update to trigger recomposition
panelState = newState.copy() // Ensure new instance
Logger.d("ChatScreen", "panelState updated: messages=${panelState.messages.size}")
Logger.d("ChatScreen", "panelState updated: messages=${panelState.messages.size}, typingUsers=${panelState.typingUsers.map { it.username }}")
}
// Initial state
panelState = panel.getState()
@@ -100,6 +100,11 @@ fun ChatScreen(
val navController = LocalNavController.current
val hazeState = rememberHazeState(blurEnabled = true)
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
LaunchedEffect(currentTypingUsers) {
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
}
// UI state
var inputText by rememberSaveable { mutableStateOf("") }
var replyTo by rememberSaveable { mutableStateOf<Message?>(null) }
@@ -113,8 +118,6 @@ fun ChatScreen(
)
)
}
var typingUsers by remember { mutableStateOf<Map<Int, String>>(emptyMap()) }
// Collect WebSocket messages
LaunchedEffect(Unit) {
WebSocketManager.messages.collect { message ->
@@ -132,7 +135,7 @@ fun ChatScreen(
val json = ApiClient.json
try {
Logger.d("ChatScreen", "Parsing updates message")
val updatesMessage = json.decodeFromJsonElement<ru.fromchat.api.UpdatesMessage>(data)
val updatesMessage = json.decodeFromJsonElement<WebSocketUpdatesData>(data)
Logger.d("ChatScreen", "Updates message parsed: ${updatesMessage.updates.size} updates")
// Process each update in the batch
updatesMessage.updates.forEach { update ->
@@ -142,7 +145,7 @@ fun ChatScreen(
data = update.data
)
when (update.type) {
"newMessage", "messageEdited", "messageDeleted" -> {
"newMessage", "messageEdited", "messageDeleted", "typing", "stopTyping", "statusUpdate", "suspended", "account_deleted" -> {
Logger.d("ChatScreen", "Launching handleWebSocketMessage for ${update.type}")
scope.launch {
try {
@@ -152,23 +155,6 @@ fun ChatScreen(
}
}
}
"typing" -> {
val updateData = update.data ?: return@forEach
val typingData = json.decodeFromJsonElement<TypingData>(updateData)
if (typingData.userId != currentUserId) {
typingUsers = typingUsers + (typingData.userId to typingData.username)
// Remove after timeout
scope.launch {
kotlinx.coroutines.delay(3000)
typingUsers = typingUsers - typingData.userId
}
}
}
"stopTyping" -> {
val updateData = update.data ?: return@forEach
val typingData = json.decodeFromJsonElement<TypingData>(updateData)
typingUsers = typingUsers - typingData.userId
}
}
}
} catch (e: Exception) {
@@ -181,18 +167,8 @@ fun ChatScreen(
panel.handleWebSocketMessage(message)
}
}
"typing" -> {
val data = message.data ?: return@collect
val json = ApiClient.json
val typingData = json.decodeFromJsonElement<TypingData>(data)
if (typingData.userId != currentUserId) {
typingUsers = typingUsers + (typingData.userId to typingData.username)
// Remove after timeout
scope.launch {
kotlinx.coroutines.delay(3000)
typingUsers = typingUsers - typingData.userId
}
}
else -> {
Logger.d("ChatScreen", "Unhandled top-level WebSocket message type: ${message.type}")
}
}
}
@@ -220,7 +196,7 @@ fun ChatScreen(
style = MaterialTheme.typography.titleLarge
)
AnimatedContent(
targetState = typingUsers.isNotEmpty(),
targetState = currentTypingUsers.isNotEmpty(),
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
@@ -228,7 +204,7 @@ fun ChatScreen(
) { hasTyping ->
if (hasTyping) {
TypingIndicator(
typingUsers = typingUsers.values.toList(),
typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp)
)
} else {
@@ -1,7 +1,7 @@
package ru.fromchat.ui.chat
import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.api.Message
import ru.fromchat.api.MessageDeletedData
@@ -25,6 +25,13 @@ class PublicChatPanel(
init {
updateState { it.copy(title = chatName) }
// Observe typing users from the handler and update panel state
scope.launch {
typingHandler.typingUsers.collect { users ->
Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}")
updateState { it.copy(typingUsers = users) }
}
}
}
private fun handleReactionUpdate(reactionUpdate: ReactionUpdateData) {
@@ -84,12 +91,12 @@ class PublicChatPanel(
}
}
private suspend fun handleSingleUpdate(updateMessage: WebSocketMessage) {
private fun handleSingleUpdate(updateMessage: WebSocketMessage) {
val json = ApiClient.json
when (updateMessage.type) {
"newMessage" -> {
val data = updateMessage.data ?: return
val newMsg = json.decodeFromJsonElement<Message>(data)
val newMsg = json.decodeFromJsonElement(Message.serializer(), data)
Logger.d("PublicChatPanel", "New message received: id=${newMsg.id}, content=${newMsg.content.take(50)}")
if (newMsg.client_message_id != null && newMsg.user_id == currentUserId) {
@@ -100,27 +107,29 @@ class PublicChatPanel(
}
"messageEdited" -> {
val data = updateMessage.data ?: return
val editedMsg = json.decodeFromJsonElement<Message>(data)
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
updateMessage(editedMsg.id) { editedMsg }
}
"messageDeleted" -> {
val data = updateMessage.data ?: return
val deletedData = json.decodeFromJsonElement<MessageDeletedData>(data)
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
removeMessage(deletedData.message_id)
}
"reactionUpdate" -> {
val data = updateMessage.data ?: return
val reactionUpdate = json.decodeFromJsonElement<ReactionUpdateData>(data)
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
handleReactionUpdate(reactionUpdate)
}
"typing" -> {
val data = updateMessage.data ?: return
val typingData = json.decodeFromJsonElement<TypingUpdateData>(data)
val typingData = json.decodeFromJsonElement(TypingUpdateData.serializer(), data)
Logger.d("PublicChatPanel", "Received typing event for user: ${typingData.username}")
typingHandler.handleTypingEvent(typingData.userId, typingData.username)
}
"stopTyping" -> {
val data = updateMessage.data ?: return
val typingData = json.decodeFromJsonElement<TypingUpdateData>(data)
val typingData = json.decodeFromJsonElement(TypingUpdateData.serializer(), data)
Logger.d("PublicChatPanel", "Received stopTyping event for user: ${typingData.username}")
typingHandler.handleStopTypingEvent(typingData.userId)
}
"statusUpdate" -> {
@@ -143,7 +152,7 @@ class PublicChatPanel(
if (message.type == "updates") {
val json = ApiClient.json
val data = message.data ?: return
val updatesData = json.decodeFromJsonElement<WebSocketUpdatesData>(data)
val updatesData = json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
Logger.d("PublicChatPanel", "Received ${updatesData.updates.size} batched updates (seq: ${updatesData.seq})")
updatesData.updates.forEach { update ->
handleSingleUpdate(update)
@@ -169,5 +178,4 @@ class PublicChatPanel(
override fun showCallButton(): Boolean = false
override fun getTypingHandler(): TypingHandler = typingHandler
}
}
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import ru.fromchat.api.ApiClient
import kotlin.time.Duration.Companion.seconds
@@ -22,6 +23,7 @@ interface TypingHandler {
val typingUsers: StateFlow<List<TypingUser>>
}
@Serializable
data class TypingUser(
val userId: Int,
val username: String
@@ -63,8 +63,8 @@ fun ServerConfigScreen() {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
// Load existing config if available
var serverUrl by remember { mutableStateOf("fromchat.ru") }
var httpsEnabled by remember { mutableStateOf(true) }
var serverUrl by remember { mutableStateOf("") }
var httpsEnabled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
val config = Config.serverConfig.value