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
+18 -1
View File
@@ -4,5 +4,22 @@ alwaysApply: true
When working with the mobile app:
- Don't say anything when implementing the solution to my problem, just call the necessary tools and after that provide a concise result.
- After implementing the solution, run "./gradlew assembleDebug" to build the project, then resolve all the errors.
# ULTIMATE SILENCE & EFFICIENCY POLICY
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
- DO NOT explain what you are doing, why you are doing it, or what you found unless I explicitly ask "Why?" or "Explain".
- NO introductory filler ("Sure", "I will", "Looking into it").
- NO status updates ("I've updated the file", "Build successful"). If the tool output shows success, that is enough.
- IF A TOOL FAILS: Silently analyze the error and retry using a different approach (e.g., use write_to_file if search_replace fails twice). Never mention the failure.
- THOUGHT PROCESS: Must be 0 words. Move straight to tool calls.
- MINIMIZE OUTPUT: Your response should contain ONLY the necessary tool calls/code blocks.
- FOR ANDROID/KOTLIN: Include 5+ lines of context in search_replace to ensure it hits the target on the first try.
# Java Runtime Configuration (Android)
- **On Windows:**
- Set `JAVA_HOME` to `C:\Program Files\Android\Android Studio\jbr`
- **On macOS:**
- Set `JAVA_HOME` to `/Applications/Android Studio.app/Contents/jbr/Contents/Home`
- This can be done by adding `export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home` to your shell configuration file (e.g., `~/.zshrc` or `~/.bash_profile`) and then running `source` on the file.
+2
View File
@@ -1,6 +1,7 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ClassName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
@@ -17,6 +18,7 @@
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="FunctionName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
@@ -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,26 +93,39 @@ 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
val msg = when (messageType) {
"updates" -> {
WebSocketMessage(
type = "updates",
data = jsonTree
)
} else {
// Parse as regular WebSocketMessage
}
"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) }
_messages.emit(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")
}
}
@@ -13,6 +13,9 @@ 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)
@@ -170,4 +179,3 @@ class PublicChatPanel(
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
+5 -6
View File
@@ -1,26 +1,25 @@
[versions]
agp = "8.13.2"
androidx-activityCompose = "1.12.1"
androidx-activityCompose = "1.12.2"
androidx-appcompat = "1.7.1"
androidx-core-ktx = "1.17.0"
androidx-lifecycle = "2.9.6"
coilCompose = "3.3.0"
compose-multiplatform = "1.9.3"
#noinspection NewerVersionAvailable
constraintlayout = "0.6.1-shaded"
coreSplashscreen = "1.2.0"
haze = "1.7.1"
kotlin = "2.2.21"
kotlin = "2.3.0"
adaptiveAndroid = "1.2.0"
kotlinStdlib = "2.2.21"
biometric = "1.4.0-alpha04"
kotlinStdlib = "2.3.0"
biometric = "1.4.0-alpha05"
gson = "2.13.2"
kotlinxIoBytestring = "0.8.2"
kotlinxCoroutinesCore = "1.10.2"
kotlinxIoCore = "0.8.2"
kotlinxSerializationJson = "1.9.0"
material = "1.13.0"
activityKtx = "1.12.1"
activityKtx = "1.12.2"
navigationCompose = "2.9.1"
datastore = "1.2.0"
ktor = "3.3.3"
+1 -1
View File
@@ -1,6 +1,6 @@
#Sat Sep 06 13:57:07 MSK 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+5
View File
@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary)
@@ -14,6 +16,9 @@ kotlin {
namespace = "com.pr0gramm3r101.utils"
compileSdk = 36
minSdk = 24
compilerOptions.jvmTarget.set(JvmTarget.JVM_17)
androidResources.enable = true
}
listOf(
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Dialog" parent="Theme.Material3.DayNight.NoActionBar" tools:keep="@style/Theme_Dialog">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">true</item>
<item name="windowActionBar">false</item>
<item name="android:windowAnimationStyle">@android:style/Animation.Dialog</item>
<item name="android:windowFullscreen">true</item>
</style>
</resources>
@@ -43,7 +43,7 @@ object ServerConfigStorage {
}
suspend fun getConfig(): ServerConfigData {
val url = getServerUrl() ?: "fromchat.ru"
val url = getServerUrl() ?: throw IllegalStateException("Server URL not found in storage")
val https = getHttpsEnabled() ?: true
return ServerConfigData(url, https)
}