mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Fix WebSocket, refactor code
This commit is contained in:
@@ -4,5 +4,22 @@ alwaysApply: true
|
|||||||
|
|
||||||
When working with the mobile app:
|
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.
|
||||||
- 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
@@ -1,6 +1,7 @@
|
|||||||
<component name="InspectionProjectProfileManager">
|
<component name="InspectionProjectProfileManager">
|
||||||
<profile version="1.0">
|
<profile version="1.0">
|
||||||
<option name="myName" value="Project Default" />
|
<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">
|
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
</inspection_tool>
|
</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">
|
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ plugins {
|
|||||||
kotlin("plugin.serialization") version "1.9.22"
|
kotlin("plugin.serialization") version "1.9.22"
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
androidTarget()
|
androidTarget()
|
||||||
|
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
@@ -27,14 +27,14 @@ plugins {
|
|||||||
isStatic = true
|
isStatic = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
all {
|
all {
|
||||||
languageSettings {
|
languageSettings {
|
||||||
optIn("kotlin.RequiresOptIn")
|
optIn("kotlin.RequiresOptIn")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(compose.preview)
|
implementation(compose.preview)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
@@ -63,21 +63,21 @@ plugins {
|
|||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
implementation(libs.kotlinx.io.core)
|
implementation(libs.kotlinx.io.core)
|
||||||
|
|
||||||
// Ktor - force version 2.3.12 to avoid conflicts with Coil 3's Ktor 3
|
// Ktor - force version 2.3.12 to avoid conflicts with Coil 3's Ktor 3
|
||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||||
implementation(libs.ktor.client.websockets)
|
implementation(libs.ktor.client.websockets)
|
||||||
implementation(libs.ktor.client.logging)
|
implementation(libs.ktor.client.logging)
|
||||||
|
|
||||||
// Datetime
|
// Datetime
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
|
|
||||||
// Coil for image loading (multiplatform)
|
// Coil for image loading (multiplatform)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.ktor3)
|
implementation(libs.coil.network.ktor3)
|
||||||
|
|
||||||
implementation(project(":utils"))
|
implementation(project(":utils"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,5 @@ package ru.fromchat.api
|
|||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.HttpClientConfig
|
import io.ktor.client.HttpClientConfig
|
||||||
import io.ktor.client.engine.okhttp.OkHttp
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
import okhttp3.Dns
|
|
||||||
|
|
||||||
actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit): HttpClient {
|
actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit) = HttpClient(OkHttp, block)
|
||||||
return HttpClient(OkHttp) {
|
|
||||||
engine {
|
|
||||||
config {
|
|
||||||
dns(Dns.SYSTEM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
block()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<network-security-config>
|
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||||
<base-config cleartextTrafficPermitted="true">
|
<base-config cleartextTrafficPermitted="true"
|
||||||
|
tools:ignore="InsecureBaseConfiguration">
|
||||||
<trust-anchors>
|
<trust-anchors>
|
||||||
<certificates src="system" />
|
<certificates src="system" />
|
||||||
<certificates src="user" />
|
|
||||||
</trust-anchors>
|
</trust-anchors>
|
||||||
</base-config>
|
</base-config>
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
@@ -11,10 +11,4 @@
|
|||||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||||
<domain includeSubdomains="true">10.0.2.2</domain>
|
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||||
</domain-config>
|
</domain-config>
|
||||||
<domain-config>
|
|
||||||
<domain includeSubdomains="true">fromchat.ru</domain>
|
|
||||||
<trust-anchors>
|
|
||||||
<certificates src="system" />
|
|
||||||
</trust-anchors>
|
|
||||||
</domain-config>
|
|
||||||
</network-security-config>
|
</network-security-config>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<string name="server_config_title">Server Configuration</string>
|
<string name="server_config_title">Server Configuration</string>
|
||||||
<string name="server_config_subtitle">Enter server details to connect</string>
|
<string name="server_config_subtitle">Enter server details to connect</string>
|
||||||
<string name="server_url_label">Server URL</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="https_enabled">Use HTTPS</string>
|
||||||
<string name="save_continue">Save & Continue</string>
|
<string name="save_continue">Save & 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.Logging
|
||||||
import io.ktor.client.plugins.logging.SIMPLE
|
import io.ktor.client.plugins.logging.SIMPLE
|
||||||
import io.ktor.client.plugins.websocket.WebSockets
|
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.bearerAuth
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.request.parameter
|
import io.ktor.client.request.parameter
|
||||||
@@ -21,6 +22,7 @@ import kotlinx.serialization.json.encodeToJsonElement
|
|||||||
import ru.fromchat.core.config.Config
|
import ru.fromchat.core.config.Config
|
||||||
import ru.fromchat.utils.failOnError
|
import ru.fromchat.utils.failOnError
|
||||||
import kotlin.concurrent.Volatile
|
import kotlin.concurrent.Volatile
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a platform-specific HTTP client that supports WebSockets
|
* Creates a platform-specific HTTP client that supports WebSockets
|
||||||
@@ -47,7 +49,9 @@ object ApiClient {
|
|||||||
level = LogLevel.INFO
|
level = LogLevel.INFO
|
||||||
}
|
}
|
||||||
|
|
||||||
install(WebSockets)
|
install(WebSockets) {
|
||||||
|
pingInterval = 5000.milliseconds // Send a ping every 5 seconds to keep the connection alive
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -58,7 +62,7 @@ object ApiClient {
|
|||||||
|
|
||||||
suspend fun login(request: LoginRequest) =
|
suspend fun login(request: LoginRequest) =
|
||||||
http
|
http
|
||||||
.post("${Config.getApiBaseUrl()}/login") {
|
.post("${Config.apiBaseUrl}/login") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") })
|
setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") })
|
||||||
}
|
}
|
||||||
@@ -71,7 +75,7 @@ object ApiClient {
|
|||||||
|
|
||||||
suspend fun register(request: RegisterRequest) =
|
suspend fun register(request: RegisterRequest) =
|
||||||
http
|
http
|
||||||
.post("${Config.getApiBaseUrl()}/register") {
|
.post("${Config.apiBaseUrl}/register") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
@@ -79,7 +83,7 @@ object ApiClient {
|
|||||||
|
|
||||||
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
|
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
|
||||||
http
|
http
|
||||||
.get("${Config.getApiBaseUrl()}/get_messages") {
|
.get("${Config.apiBaseUrl}/get_messages") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
bearerAuth(token ?: throw IllegalStateException("Not authenticated"))
|
bearerAuth(token ?: throw IllegalStateException("Not authenticated"))
|
||||||
parameter("limit", limit)
|
parameter("limit", limit)
|
||||||
@@ -90,7 +94,7 @@ object ApiClient {
|
|||||||
|
|
||||||
suspend fun send(message: String) =
|
suspend fun send(message: String) =
|
||||||
http
|
http
|
||||||
.post("${Config.getApiBaseUrl()}/send_message") {
|
.post("${Config.apiBaseUrl}/send_message") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
bearerAuth(token!!)
|
bearerAuth(token!!)
|
||||||
setBody(SendMessageRequest(message))
|
setBody(SendMessageRequest(message))
|
||||||
@@ -102,7 +106,7 @@ object ApiClient {
|
|||||||
suspend fun logout(authToken: String) {
|
suspend fun logout(authToken: String) {
|
||||||
// Don't throw on logout errors, just try to logout
|
// Don't throw on logout errors, just try to logout
|
||||||
try {
|
try {
|
||||||
http.get("${Config.getApiBaseUrl()}/logout") {
|
http.get("${Config.apiBaseUrl}/logout") {
|
||||||
bearerAuth(authToken)
|
bearerAuth(authToken)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -37,3 +37,7 @@ data class TypingUpdateData(
|
|||||||
val username: String
|
val username: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class WebSocketAuthMessage(
|
||||||
|
val token: String
|
||||||
|
)
|
||||||
@@ -47,38 +47,44 @@ object WebSocketManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@Volatile
|
@Volatile private var connecting = false
|
||||||
private var connecting: Boolean = false
|
|
||||||
@Volatile private var session: DefaultClientWebSocketSession? = null
|
@Volatile private var session: DefaultClientWebSocketSession? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if WebSocket is connected
|
* Check if WebSocket is connected
|
||||||
*/
|
*/
|
||||||
fun isConnected(): Boolean = session != null
|
val isConnected get() = session != null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for WebSocket connection with timeout
|
* Wait for WebSocket connection with timeout
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalTime::class)
|
@OptIn(ExperimentalTime::class)
|
||||||
suspend fun waitForConnection(timeoutMs: Long = 10000): Boolean {
|
suspend fun waitForConnection(timeoutMs: Long = 10000): Boolean {
|
||||||
|
Logger.d("WebSocketManager", "waitForConnection: session=${session != null}, connecting=$connecting")
|
||||||
if (session != null) return true
|
if (session != null) return true
|
||||||
val startTime = Clock.System.now().toEpochMilliseconds()
|
val startTime = Clock.System.now().toEpochMilliseconds()
|
||||||
while (session == null && (Clock.System.now().toEpochMilliseconds() - startTime) < timeoutMs) {
|
while (session == null && (Clock.System.now().toEpochMilliseconds() - startTime) < timeoutMs) {
|
||||||
delay(100)
|
delay(100)
|
||||||
}
|
}
|
||||||
|
Logger.d("WebSocketManager", "waitForConnection finished: session=${session != null}")
|
||||||
return session != null
|
return session != null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connect() {
|
fun connect() {
|
||||||
Logger.d("WebSocketManager", "Connecting to WebSocket")
|
Logger.d("WebSocketManager", "connect() called. current session=${session != null}, connecting=$connecting")
|
||||||
if (connecting) return
|
if (connecting) {
|
||||||
|
Logger.d("WebSocketManager", "connect() ignored: already connecting")
|
||||||
|
return
|
||||||
|
}
|
||||||
connecting = true
|
connecting = true
|
||||||
|
Logger.d("WebSocketManager", "connecting set to true")
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
|
Logger.d("WebSocketManager", "Connection loop active. isActive=$isActive")
|
||||||
try {
|
try {
|
||||||
val wsUrl = Config.getWebSocketUrl()
|
val wsUrl = Config.webSocketUrl
|
||||||
Logger.d("WebSocketManager", "Connecting to: $wsUrl")
|
Logger.d("WebSocketManager", "Attempting to connect to: $wsUrl")
|
||||||
ApiClient.http.webSocket(
|
ApiClient.http.webSocket(
|
||||||
method = HttpMethod.Get,
|
method = HttpMethod.Get,
|
||||||
request = {
|
request = {
|
||||||
@@ -87,25 +93,38 @@ object WebSocketManager {
|
|||||||
) {
|
) {
|
||||||
session = this
|
session = this
|
||||||
connecting = false
|
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) {
|
for (frame in incoming) {
|
||||||
val text = (frame as? Frame.Text)?.readText() ?: continue
|
val text = (frame as? Frame.Text)?.readText() ?: continue
|
||||||
Logger.d("WebSocketManager", "Received payload: $text")
|
Logger.d("WebSocketManager", "Received payload: $text")
|
||||||
try {
|
try {
|
||||||
// Check if this is an "updates" message - it has a different structure
|
|
||||||
val jsonTree = json.parseToJsonElement(text)
|
val jsonTree = json.parseToJsonElement(text)
|
||||||
val messageType = jsonTree.jsonObject["type"]?.jsonPrimitive?.content
|
val messageType = jsonTree.jsonObject["type"]?.jsonPrimitive?.content
|
||||||
|
|
||||||
val msg = if (messageType == "updates") {
|
val msg = when (messageType) {
|
||||||
// Wrap in WebSocketMessage with the entire JSON tree as data
|
"updates" -> {
|
||||||
WebSocketMessage(
|
WebSocketMessage(
|
||||||
type = "updates",
|
type = "updates",
|
||||||
data = jsonTree
|
data = jsonTree
|
||||||
)
|
)
|
||||||
} else {
|
}
|
||||||
// Parse as regular WebSocketMessage
|
"typing", "stopTyping" -> {
|
||||||
json.decodeFromString<WebSocketMessage>(text)
|
// 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) }
|
globalHandlers.forEach { it(msg) }
|
||||||
@@ -117,12 +136,13 @@ object WebSocketManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} 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
|
connecting = false
|
||||||
session = null
|
session = null
|
||||||
|
Logger.d("WebSocketManager", "Reconnecting in 3 seconds...")
|
||||||
delay(3000)
|
delay(3000)
|
||||||
} finally {
|
} finally {
|
||||||
Logger.w("WebSocketManager", "WebSocket disconnected")
|
Logger.w("WebSocketManager", "WebSocket disconnected. session set to null, connecting set to false")
|
||||||
session = null
|
session = null
|
||||||
connecting = false
|
connecting = false
|
||||||
}
|
}
|
||||||
@@ -190,6 +210,15 @@ object WebSocketManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun shutdown() {
|
fun shutdown() {
|
||||||
|
Logger.d("WebSocketManager", "shutdown() called. Cancelling scope.")
|
||||||
scope.cancel()
|
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 {
|
object Config {
|
||||||
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
|
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
|
||||||
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
|
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
|
||||||
|
|
||||||
|
private val config
|
||||||
|
get() = _serverConfig.value ?: throw IllegalStateException("Server configuration not initialized")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize configuration by loading from storage
|
* Initialize configuration by loading from storage
|
||||||
@@ -31,25 +34,17 @@ object Config {
|
|||||||
/**
|
/**
|
||||||
* Get API base URL based on current server configuration
|
* Get API base URL based on current server configuration
|
||||||
*/
|
*/
|
||||||
fun getApiBaseUrl(): String {
|
val apiBaseUrl
|
||||||
val config = _serverConfig.value ?: ServerConfigData("fromchat.ru", true)
|
get() = "${if (config.httpsEnabled) "https" else "http"}://${config.serverUrl}/api"
|
||||||
val protocol = if (config.httpsEnabled) "https" else "http"
|
|
||||||
return "$protocol://${config.serverUrl}/api"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get WebSocket URL based on current server configuration
|
* Get WebSocket URL based on current server configuration
|
||||||
*/
|
*/
|
||||||
fun getWebSocketUrl(): String {
|
val webSocketUrl
|
||||||
val config = _serverConfig.value ?: ServerConfigData("fromchat.ru", true)
|
get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws"
|
||||||
val protocol = if (config.httpsEnabled) "wss" else "ws"
|
|
||||||
return "$protocol://${config.serverUrl}/api/chat/ws"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if server configuration exists
|
* Checks if server configuration exists
|
||||||
*/
|
*/
|
||||||
suspend fun hasServerConfig(): Boolean {
|
suspend fun hasServerConfig() = ServerConfigStorage.hasConfiguration()
|
||||||
return ServerConfigStorage.hasConfiguration()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -15,7 +16,11 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.unit.IntOffset
|
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.NavController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
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 {
|
FromChatTheme {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val animationSpec = tween<IntOffset>(400)
|
val animationSpec = tween<IntOffset>(400)
|
||||||
@@ -111,8 +139,6 @@ fun App() {
|
|||||||
composable("login") {
|
composable("login") {
|
||||||
LoginScreen(
|
LoginScreen(
|
||||||
onLoginSuccess = {
|
onLoginSuccess = {
|
||||||
// Ensure WebSocket is connected after login
|
|
||||||
WebSocketManager.connect()
|
|
||||||
navController.navigate("chat") {
|
navController.navigate("chat") {
|
||||||
popUpTo("login") { inclusive = true }
|
popUpTo("login") { inclusive = true }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ fun Avatar(
|
|||||||
val fullUrl = if (profilePictureUrl.startsWith("http")) {
|
val fullUrl = if (profilePictureUrl.startsWith("http")) {
|
||||||
profilePictureUrl
|
profilePictureUrl
|
||||||
} else {
|
} else {
|
||||||
"${Config.getApiBaseUrl()}$profilePictureUrl"
|
"${Config.apiBaseUrl}$profilePictureUrl"
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ data class ChatPanelState(
|
|||||||
val messages: List<Message> = emptyList(),
|
val messages: List<Message> = emptyList(),
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val hasMoreMessages: 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.Res
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.Message
|
import ru.fromchat.api.Message
|
||||||
import ru.fromchat.api.TypingData
|
|
||||||
import ru.fromchat.api.WebSocketManager
|
import ru.fromchat.api.WebSocketManager
|
||||||
import ru.fromchat.api.WebSocketMessage
|
import ru.fromchat.api.WebSocketMessage
|
||||||
|
import ru.fromchat.api.WebSocketUpdatesData
|
||||||
import ru.fromchat.back
|
import ru.fromchat.back
|
||||||
import ru.fromchat.core.Logger
|
import ru.fromchat.core.Logger
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
@@ -80,10 +80,10 @@ fun ChatScreen(
|
|||||||
// Observe state changes
|
// Observe state changes
|
||||||
LaunchedEffect(panel) {
|
LaunchedEffect(panel) {
|
||||||
panel.setOnStateChange { newState ->
|
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
|
// Force state update to trigger recomposition
|
||||||
panelState = newState.copy() // Ensure new instance
|
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
|
// Initial state
|
||||||
panelState = panel.getState()
|
panelState = panel.getState()
|
||||||
@@ -100,6 +100,11 @@ fun ChatScreen(
|
|||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
val hazeState = rememberHazeState(blurEnabled = true)
|
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
|
// UI state
|
||||||
var inputText by rememberSaveable { mutableStateOf("") }
|
var inputText by rememberSaveable { mutableStateOf("") }
|
||||||
var replyTo by rememberSaveable { mutableStateOf<Message?>(null) }
|
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
|
// Collect WebSocket messages
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
WebSocketManager.messages.collect { message ->
|
WebSocketManager.messages.collect { message ->
|
||||||
@@ -132,7 +135,7 @@ fun ChatScreen(
|
|||||||
val json = ApiClient.json
|
val json = ApiClient.json
|
||||||
try {
|
try {
|
||||||
Logger.d("ChatScreen", "Parsing updates message")
|
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")
|
Logger.d("ChatScreen", "Updates message parsed: ${updatesMessage.updates.size} updates")
|
||||||
// Process each update in the batch
|
// Process each update in the batch
|
||||||
updatesMessage.updates.forEach { update ->
|
updatesMessage.updates.forEach { update ->
|
||||||
@@ -142,7 +145,7 @@ fun ChatScreen(
|
|||||||
data = update.data
|
data = update.data
|
||||||
)
|
)
|
||||||
when (update.type) {
|
when (update.type) {
|
||||||
"newMessage", "messageEdited", "messageDeleted" -> {
|
"newMessage", "messageEdited", "messageDeleted", "typing", "stopTyping", "statusUpdate", "suspended", "account_deleted" -> {
|
||||||
Logger.d("ChatScreen", "Launching handleWebSocketMessage for ${update.type}")
|
Logger.d("ChatScreen", "Launching handleWebSocketMessage for ${update.type}")
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
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) {
|
} catch (e: Exception) {
|
||||||
@@ -181,18 +167,8 @@ fun ChatScreen(
|
|||||||
panel.handleWebSocketMessage(message)
|
panel.handleWebSocketMessage(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"typing" -> {
|
else -> {
|
||||||
val data = message.data ?: return@collect
|
Logger.d("ChatScreen", "Unhandled top-level WebSocket message type: ${message.type}")
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,7 +196,7 @@ fun ChatScreen(
|
|||||||
style = MaterialTheme.typography.titleLarge
|
style = MaterialTheme.typography.titleLarge
|
||||||
)
|
)
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = typingUsers.isNotEmpty(),
|
targetState = currentTypingUsers.isNotEmpty(),
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
fadeIn() togetherWith fadeOut()
|
fadeIn() togetherWith fadeOut()
|
||||||
},
|
},
|
||||||
@@ -228,7 +204,7 @@ fun ChatScreen(
|
|||||||
) { hasTyping ->
|
) { hasTyping ->
|
||||||
if (hasTyping) {
|
if (hasTyping) {
|
||||||
TypingIndicator(
|
TypingIndicator(
|
||||||
typingUsers = typingUsers.values.toList(),
|
typingUsers = currentTypingUsers.map { it.username },
|
||||||
modifier = Modifier.padding(top = 2.dp)
|
modifier = Modifier.padding(top = 2.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package ru.fromchat.ui.chat
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.coroutines.launch
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.Message
|
import ru.fromchat.api.Message
|
||||||
import ru.fromchat.api.MessageDeletedData
|
import ru.fromchat.api.MessageDeletedData
|
||||||
@@ -25,6 +25,13 @@ class PublicChatPanel(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
updateState { it.copy(title = chatName) }
|
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) {
|
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
|
val json = ApiClient.json
|
||||||
when (updateMessage.type) {
|
when (updateMessage.type) {
|
||||||
"newMessage" -> {
|
"newMessage" -> {
|
||||||
val data = updateMessage.data ?: return
|
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)}")
|
Logger.d("PublicChatPanel", "New message received: id=${newMsg.id}, content=${newMsg.content.take(50)}")
|
||||||
|
|
||||||
if (newMsg.client_message_id != null && newMsg.user_id == currentUserId) {
|
if (newMsg.client_message_id != null && newMsg.user_id == currentUserId) {
|
||||||
@@ -100,27 +107,29 @@ class PublicChatPanel(
|
|||||||
}
|
}
|
||||||
"messageEdited" -> {
|
"messageEdited" -> {
|
||||||
val data = updateMessage.data ?: return
|
val data = updateMessage.data ?: return
|
||||||
val editedMsg = json.decodeFromJsonElement<Message>(data)
|
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
|
||||||
updateMessage(editedMsg.id) { editedMsg }
|
updateMessage(editedMsg.id) { editedMsg }
|
||||||
}
|
}
|
||||||
"messageDeleted" -> {
|
"messageDeleted" -> {
|
||||||
val data = updateMessage.data ?: return
|
val data = updateMessage.data ?: return
|
||||||
val deletedData = json.decodeFromJsonElement<MessageDeletedData>(data)
|
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
|
||||||
removeMessage(deletedData.message_id)
|
removeMessage(deletedData.message_id)
|
||||||
}
|
}
|
||||||
"reactionUpdate" -> {
|
"reactionUpdate" -> {
|
||||||
val data = updateMessage.data ?: return
|
val data = updateMessage.data ?: return
|
||||||
val reactionUpdate = json.decodeFromJsonElement<ReactionUpdateData>(data)
|
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
|
||||||
handleReactionUpdate(reactionUpdate)
|
handleReactionUpdate(reactionUpdate)
|
||||||
}
|
}
|
||||||
"typing" -> {
|
"typing" -> {
|
||||||
val data = updateMessage.data ?: return
|
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)
|
typingHandler.handleTypingEvent(typingData.userId, typingData.username)
|
||||||
}
|
}
|
||||||
"stopTyping" -> {
|
"stopTyping" -> {
|
||||||
val data = updateMessage.data ?: return
|
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)
|
typingHandler.handleStopTypingEvent(typingData.userId)
|
||||||
}
|
}
|
||||||
"statusUpdate" -> {
|
"statusUpdate" -> {
|
||||||
@@ -143,7 +152,7 @@ class PublicChatPanel(
|
|||||||
if (message.type == "updates") {
|
if (message.type == "updates") {
|
||||||
val json = ApiClient.json
|
val json = ApiClient.json
|
||||||
val data = message.data ?: return
|
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})")
|
Logger.d("PublicChatPanel", "Received ${updatesData.updates.size} batched updates (seq: ${updatesData.seq})")
|
||||||
updatesData.updates.forEach { update ->
|
updatesData.updates.forEach { update ->
|
||||||
handleSingleUpdate(update)
|
handleSingleUpdate(update)
|
||||||
@@ -169,5 +178,4 @@ class PublicChatPanel(
|
|||||||
override fun showCallButton(): Boolean = false
|
override fun showCallButton(): Boolean = false
|
||||||
|
|
||||||
override fun getTypingHandler(): TypingHandler = typingHandler
|
override fun getTypingHandler(): TypingHandler = typingHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ interface TypingHandler {
|
|||||||
val typingUsers: StateFlow<List<TypingUser>>
|
val typingUsers: StateFlow<List<TypingUser>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class TypingUser(
|
data class TypingUser(
|
||||||
val userId: Int,
|
val userId: Int,
|
||||||
val username: String
|
val username: String
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ fun ServerConfigScreen() {
|
|||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
|
|
||||||
// Load existing config if available
|
// Load existing config if available
|
||||||
var serverUrl by remember { mutableStateOf("fromchat.ru") }
|
var serverUrl by remember { mutableStateOf("") }
|
||||||
var httpsEnabled by remember { mutableStateOf(true) }
|
var httpsEnabled by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
val config = Config.serverConfig.value
|
val config = Config.serverConfig.value
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.13.2"
|
agp = "8.13.2"
|
||||||
androidx-activityCompose = "1.12.1"
|
androidx-activityCompose = "1.12.2"
|
||||||
androidx-appcompat = "1.7.1"
|
androidx-appcompat = "1.7.1"
|
||||||
androidx-core-ktx = "1.17.0"
|
androidx-core-ktx = "1.17.0"
|
||||||
androidx-lifecycle = "2.9.6"
|
|
||||||
coilCompose = "3.3.0"
|
coilCompose = "3.3.0"
|
||||||
compose-multiplatform = "1.9.3"
|
compose-multiplatform = "1.9.3"
|
||||||
#noinspection NewerVersionAvailable
|
#noinspection NewerVersionAvailable
|
||||||
constraintlayout = "0.6.1-shaded"
|
constraintlayout = "0.6.1-shaded"
|
||||||
coreSplashscreen = "1.2.0"
|
coreSplashscreen = "1.2.0"
|
||||||
haze = "1.7.1"
|
haze = "1.7.1"
|
||||||
kotlin = "2.2.21"
|
kotlin = "2.3.0"
|
||||||
adaptiveAndroid = "1.2.0"
|
adaptiveAndroid = "1.2.0"
|
||||||
kotlinStdlib = "2.2.21"
|
kotlinStdlib = "2.3.0"
|
||||||
biometric = "1.4.0-alpha04"
|
biometric = "1.4.0-alpha05"
|
||||||
gson = "2.13.2"
|
gson = "2.13.2"
|
||||||
kotlinxIoBytestring = "0.8.2"
|
kotlinxIoBytestring = "0.8.2"
|
||||||
kotlinxCoroutinesCore = "1.10.2"
|
kotlinxCoroutinesCore = "1.10.2"
|
||||||
kotlinxIoCore = "0.8.2"
|
kotlinxIoCore = "0.8.2"
|
||||||
kotlinxSerializationJson = "1.9.0"
|
kotlinxSerializationJson = "1.9.0"
|
||||||
material = "1.13.0"
|
material = "1.13.0"
|
||||||
activityKtx = "1.12.1"
|
activityKtx = "1.12.2"
|
||||||
navigationCompose = "2.9.1"
|
navigationCompose = "2.9.1"
|
||||||
datastore = "1.2.0"
|
datastore = "1.2.0"
|
||||||
ktor = "3.3.3"
|
ktor = "3.3.3"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
#Sat Sep 06 13:57:07 MSK 2025
|
#Sat Sep 06 13:57:07 MSK 2025
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.androidKotlinMultiplatformLibrary)
|
alias(libs.plugins.androidKotlinMultiplatformLibrary)
|
||||||
@@ -14,6 +16,9 @@ kotlin {
|
|||||||
namespace = "com.pr0gramm3r101.utils"
|
namespace = "com.pr0gramm3r101.utils"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
|
|
||||||
|
compilerOptions.jvmTarget.set(JvmTarget.JVM_17)
|
||||||
|
androidResources.enable = true
|
||||||
}
|
}
|
||||||
|
|
||||||
listOf(
|
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 {
|
suspend fun getConfig(): ServerConfigData {
|
||||||
val url = getServerUrl() ?: "fromchat.ru"
|
val url = getServerUrl() ?: throw IllegalStateException("Server URL not found in storage")
|
||||||
val https = getHttpsEnabled() ?: true
|
val https = getHttpsEnabled() ?: true
|
||||||
return ServerConfigData(url, https)
|
return ServerConfigData(url, https)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user